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,3744 @@
1
+ 'use strict';
2
+
3
+ var jspsych = require('jspsych');
4
+ var React = require('react');
5
+ var client = require('react-dom/client');
6
+ var uuid = require('uuid');
7
+
8
+ const CONFIG = {
9
+ color: {
10
+ bands: {
11
+ silhouette: { fillEven: "#eef2ff", fillOdd: "#f6f7fb", stroke: "#c7d2fe" },
12
+ workspace: { fillEven: "#f3f4f6", fillOdd: "#f9fafb", stroke: "#e5e7eb" }
13
+ },
14
+ completion: { fill: "#dcfce7", stroke: "#86efac" },
15
+ silhouetteMask: "#94a3b8",
16
+ anchors: { invalid: "#7dd3fc", valid: "#475569" },
17
+ piece: { draggingFill: "#1d4ed8", validFill: "#60a5fa", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#111827", allGreenStroke: "#86efac", borderStroke: "#374151" },
18
+ ui: { light: "#60a5fa", dark: "#1d4ed8" },
19
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#eef2ff", labelFill: "#374151" }
20
+ },
21
+ opacity: {
22
+ blueprint: 0.95,
23
+ silhouetteMask: 0.45,
24
+ anchors: { valid: 0.8, invalid: 0.5 },
25
+ piece: { invalid: 0.35, dragging: 0.6, locked: 0.7, normal: 0.95 }
26
+ },
27
+ size: {
28
+ stroke: { bandPx: 1, pieceSelectedPx: 1.5, allGreenStrokePx: 6, pieceBorderPx: 1 },
29
+ anchorRadiusPx: { valid: 1, invalid: 1 },
30
+ badgeFontPx: 12,
31
+ centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
32
+ },
33
+ layout: {
34
+ grid: { stepPx: 20, unitPx: 40 },
35
+ paddingPx: 1,
36
+ constraints: {
37
+ workspaceDiamAnchors: 10,
38
+ // num anchors req'd to be on diagonal
39
+ quickstashDiamAnchors: 7,
40
+ // num anchors req'd to be in single quickstash slot
41
+ primitiveDiamAnchors: 5
42
+ },
43
+ defaults: { maxQuickstashSlots: 1 }
44
+ },
45
+ game: {
46
+ snapRadiusPx: 15,
47
+ showBorders: true,
48
+ hideTouchingBorders: true
49
+ }
50
+ };
51
+
52
+ function isComposite(bp) {
53
+ return bp.parts !== void 0;
54
+ }
55
+ function isPrimitive(bp) {
56
+ return !isComposite(bp);
57
+ }
58
+ class BlueprintRegistry {
59
+ constructor() {
60
+ this.byId = /* @__PURE__ */ new Map();
61
+ this.primitivesByKind = /* @__PURE__ */ new Map();
62
+ }
63
+ registerAll(bps) {
64
+ for (const bp of bps) this.register(bp);
65
+ }
66
+ register(bp) {
67
+ this.byId.set(bp.id, bp);
68
+ if (isPrimitive(bp)) this.primitivesByKind.set(bp.kind, bp);
69
+ }
70
+ get(id) {
71
+ return this.byId.get(id);
72
+ }
73
+ getPrimitive(kind) {
74
+ return this.primitivesByKind.get(kind);
75
+ }
76
+ }
77
+
78
+ let PID = 0;
79
+ const newId = () => `p_${++PID}`;
80
+ const NOW = () => performance.now();
81
+ class BaseGameController {
82
+ constructor(sectors, quickstash, primitives, config) {
83
+ this.registry = new BlueprintRegistry();
84
+ // React state update tracking
85
+ this._updateCount = 0;
86
+ // Event tracking (optional)
87
+ this.trackingCallbacks = null;
88
+ if (!sectors || sectors.length === 0) {
89
+ throw new Error("BaseGameController: sectors array is required and cannot be empty");
90
+ }
91
+ if (!primitives || primitives.length === 0) {
92
+ throw new Error("BaseGameController: primitives array is required and cannot be empty");
93
+ }
94
+ if (!config) {
95
+ throw new Error("BaseGameController: config is required");
96
+ }
97
+ this.sectors = sectors;
98
+ this.quickstash = quickstash || [];
99
+ this.primitives = primitives;
100
+ this.config = config;
101
+ this.registry.registerAll(primitives);
102
+ this.registry.registerAll(quickstash);
103
+ this.t0 = NOW();
104
+ this.state = this.initializeState();
105
+ }
106
+ get updateCount() {
107
+ return this._updateCount;
108
+ }
109
+ notifyStateChange() {
110
+ this._updateCount++;
111
+ }
112
+ initializeState() {
113
+ const sectors = {};
114
+ for (const s of this.sectors) {
115
+ sectors[s.id] = { sectorId: s.id, pieces: [] };
116
+ }
117
+ return {
118
+ cfg: {
119
+ n: this.config.n,
120
+ layout: this.config.layout,
121
+ target: this.config.target,
122
+ input: this.config.input,
123
+ maxQuickstashSlots: this.config.maxQuickstashSlots,
124
+ timeLimitMs: this.config.timeLimitMs,
125
+ snapRadiusPx: CONFIG.game.snapRadiusPx,
126
+ maxCompositeSize: this.config.maxCompositeSize ?? 0,
127
+ // Default to 0 for legacy compatibility
128
+ sectors: this.sectors,
129
+ mode: this.config.mode
130
+ },
131
+ blueprintView: this.config.mode === "prep" ? "primitives" : "quickstash",
132
+ // Start with primitives in prep mode
133
+ primitives: this.primitives,
134
+ quickstash: this.quickstash,
135
+ sectors,
136
+ startedAt: this.t0
137
+ };
138
+ }
139
+ // ===== Event Tracking =====
140
+ // Methods for InteractionTracker integration
141
+ /**
142
+ * Set tracking callbacks for data collection
143
+ * Called by InteractionTracker to register itself
144
+ */
145
+ setTrackingCallbacks(callbacks) {
146
+ this.trackingCallbacks = callbacks;
147
+ }
148
+ // ===== RoundController Compatibility Interface =====
149
+ // These methods maintain compatibility with Board.tsx expectations
150
+ getState() {
151
+ return this.state;
152
+ }
153
+ getSnapRadius() {
154
+ return CONFIG.game.snapRadiusPx;
155
+ }
156
+ // CRITICAL: Preserve target and input mode access
157
+ getTarget() {
158
+ return this.config.target;
159
+ }
160
+ getInputMode() {
161
+ return this.config.input;
162
+ }
163
+ // ===== Blueprint and Piece Management =====
164
+ // Methods that Board.tsx expects
165
+ getBlueprint(id) {
166
+ return this.registry.get(id);
167
+ }
168
+ getPrimitive(kind) {
169
+ return this.registry.getPrimitive(kind);
170
+ }
171
+ getPiecesInSector(sectorId) {
172
+ const sectorState = this.state.sectors[sectorId];
173
+ return sectorState ? sectorState.pieces : [];
174
+ }
175
+ // ===== Completion Management =====
176
+ // Critical for Board.tsx validation
177
+ isSectorCompleted(sectorId) {
178
+ return !!this.state.sectors[sectorId]?.completedAt;
179
+ }
180
+ markSectorCompleted(sectorId) {
181
+ const ss = this.state.sectors[sectorId];
182
+ if (!ss || ss.completedAt) return;
183
+ ss.completedAt = NOW();
184
+ if (this.trackingCallbacks?.onSectorCompleted) {
185
+ this.trackingCallbacks.onSectorCompleted(sectorId);
186
+ }
187
+ const allDone = Object.values(this.state.sectors).every((s) => !!s.completedAt);
188
+ if (allDone && !this.state.endedAt) {
189
+ this.state.endedAt = NOW();
190
+ console.log("[BaseGameController] all sectors complete");
191
+ }
192
+ }
193
+ // ===== Piece Operations =====
194
+ // Methods that Board.tsx uses for drag/drop
195
+ spawnFromBlueprint(bp, at) {
196
+ const id = newId();
197
+ const piece = { id, blueprintId: bp.id, pos: { ...at } };
198
+ this.getFloating().pieces.push(piece);
199
+ this.notifyStateChange();
200
+ return id;
201
+ }
202
+ move(id, to) {
203
+ const p = this.findPiece(id);
204
+ if (!p) {
205
+ return;
206
+ }
207
+ p.pos = { ...to };
208
+ this.notifyStateChange();
209
+ }
210
+ drop(id, sectorId) {
211
+ const p = this.findPiece(id);
212
+ if (!p) {
213
+ return;
214
+ }
215
+ const where = this.findPieceLocation(id);
216
+ this.removeFromCurrent(id);
217
+ if (where?.sectorId) delete p.sectorId;
218
+ if (!sectorId) {
219
+ this.getFloating().pieces.push(p);
220
+ this.notifyStateChange();
221
+ return;
222
+ }
223
+ if (this.isSectorCompleted(sectorId)) {
224
+ this.notifyStateChange();
225
+ return;
226
+ }
227
+ const sector = this.state.sectors[sectorId];
228
+ if (!sector) {
229
+ return;
230
+ }
231
+ sector.pieces.push(p);
232
+ p.sectorId = sectorId;
233
+ this.notifyStateChange();
234
+ }
235
+ remove(id) {
236
+ const loc = this.findPieceLocation(id);
237
+ if (!loc) return;
238
+ loc.array.splice(loc.index, 1);
239
+ this.notifyStateChange();
240
+ }
241
+ // ===== Helper Methods =====
242
+ // Internal methods that support the public API
243
+ findPiece(id) {
244
+ for (const s of Object.values(this.state.sectors)) {
245
+ const sectorState = s;
246
+ const p = sectorState.pieces.find((pp) => pp.id === id);
247
+ if (p) return p;
248
+ }
249
+ return this.getFloating().pieces.find((pp) => pp.id === id) ?? null;
250
+ }
251
+ getFloating() {
252
+ if (!this._floating) {
253
+ this._floating = { sectorId: "-floating-", pieces: [] };
254
+ }
255
+ return this._floating;
256
+ }
257
+ findPieceLocation(id) {
258
+ for (const s of Object.values(this.state.sectors)) {
259
+ const sectorState = s;
260
+ const idx = sectorState.pieces.findIndex((pp) => pp.id === id);
261
+ if (idx >= 0) return { sectorId: sectorState.sectorId, array: sectorState.pieces, index: idx };
262
+ }
263
+ const f = this.getFloating();
264
+ const i = f.pieces.findIndex((pp) => pp.id === id);
265
+ return i >= 0 ? { array: f.pieces, index: i } : null;
266
+ }
267
+ removeFromCurrent(id) {
268
+ const loc = this.findPieceLocation(id);
269
+ if (!loc) return;
270
+ loc.array.splice(loc.index, 1);
271
+ }
272
+ // ===== Additional Compatibility Methods =====
273
+ switchBlueprintView() {
274
+ if (this.config.mode === "prep") return;
275
+ const to = this.state.blueprintView === "primitives" ? "quickstash" : "primitives";
276
+ this.state.blueprintView = to;
277
+ }
278
+ /**
279
+ * Check if submit button should be enabled in prep mode
280
+ * Validates macro completion based on piece count and slot requirements
281
+ */
282
+ isSubmitEnabled() {
283
+ if (this.config.mode !== "prep") return true;
284
+ const minPieces = this.config.minPiecesPerMacro ?? 2;
285
+ const maxPieces = this.config.maxCompositeSize ?? Infinity;
286
+ const requireAll = this.config.requireAllSlots ?? false;
287
+ const allSectors = Object.values(this.state.sectors);
288
+ let validSectorCount = 0;
289
+ for (const sector of allSectors) {
290
+ const pieceCount = sector.pieces.length;
291
+ if (pieceCount >= minPieces && pieceCount <= maxPieces) {
292
+ validSectorCount++;
293
+ } else if (pieceCount > 0) {
294
+ return false;
295
+ }
296
+ }
297
+ if (requireAll) {
298
+ return validSectorCount === allSectors.length;
299
+ } else {
300
+ return true;
301
+ }
302
+ }
303
+ snapshot() {
304
+ const perSector = Object.values(this.state.sectors).map((s) => {
305
+ const sectorState = s;
306
+ const base = {
307
+ sectorId: sectorState.sectorId,
308
+ pieceCount: sectorState.pieces.length
309
+ };
310
+ return sectorState.completedAt ? { ...base, completedAt: sectorState.completedAt } : base;
311
+ });
312
+ return {
313
+ perSector,
314
+ events: [],
315
+ // TODO: Will be implemented in validation separation phase
316
+ timeMs: NOW() - this.t0,
317
+ completed: !!this.state.endedAt
318
+ };
319
+ }
320
+ }
321
+
322
+ function pointOnSegment(p, a, b, eps = 1e-9) {
323
+ const cross = (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
324
+ if (Math.abs(cross) > eps) return false;
325
+ const minX = Math.min(a.x, b.x) - eps, maxX = Math.max(a.x, b.x) + eps;
326
+ const minY = Math.min(a.y, b.y) - eps, maxY = Math.max(a.y, b.y) + eps;
327
+ return p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY;
328
+ }
329
+ function pointInPolygon(pt, poly) {
330
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
331
+ const vertexJ = poly[j];
332
+ const vertexI = poly[i];
333
+ if (!vertexJ || !vertexI) continue;
334
+ if (pointOnSegment(pt, vertexJ, vertexI)) return true;
335
+ }
336
+ let inside = false;
337
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
338
+ const vertexI = poly[i];
339
+ const vertexJ = poly[j];
340
+ if (!vertexI || !vertexJ) continue;
341
+ const xi = vertexI.x, yi = vertexI.y;
342
+ const xj = vertexJ.x, yj = vertexJ.y;
343
+ const intersects = yi > pt.y !== yj > pt.y && pt.x < (xj - xi) * (pt.y - yi) / (yj - yi + 1e-12) + xi;
344
+ if (intersects) inside = !inside;
345
+ }
346
+ return inside;
347
+ }
348
+ function project(poly, nx, ny) {
349
+ let min = Infinity, max = -Infinity;
350
+ for (const p of poly) {
351
+ const s = p.x * nx + p.y * ny;
352
+ if (s < min) min = s;
353
+ if (s > max) max = s;
354
+ }
355
+ return { min, max };
356
+ }
357
+
358
+ const SEP_EPS = 1e-6;
359
+ function convexIntersects(a, b) {
360
+ const polys = [a, b];
361
+ for (const poly of polys) {
362
+ for (let i = 0; i < poly.length; i++) {
363
+ const p0 = poly[i], p1 = poly[(i + 1) % poly.length];
364
+ if (!p0 || !p1) continue;
365
+ const ex = p1.x - p0.x, ey = p1.y - p0.y;
366
+ const nx = -ey, ny = ex;
367
+ const pa = project(a, nx, ny);
368
+ const pb = project(b, nx, ny);
369
+ if (pa.max <= pb.min + SEP_EPS || pb.max <= pa.min + SEP_EPS) return false;
370
+ }
371
+ }
372
+ return true;
373
+ }
374
+ function polysOverlap(aPolys, bPolys) {
375
+ const aabbsA = aPolys.map((p) => {
376
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
377
+ for (const pt of p) {
378
+ if (pt.x < minX) minX = pt.x;
379
+ if (pt.y < minY) minY = pt.y;
380
+ if (pt.x > maxX) maxX = pt.x;
381
+ if (pt.y > maxY) maxY = pt.y;
382
+ }
383
+ return { minX, minY, maxX, maxY };
384
+ });
385
+ const aabbsB = bPolys.map((p) => {
386
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
387
+ for (const pt of p) {
388
+ if (pt.x < minX) minX = pt.x;
389
+ if (pt.y < minY) minY = pt.y;
390
+ if (pt.x > maxX) maxX = pt.x;
391
+ if (pt.y > maxY) maxY = pt.y;
392
+ }
393
+ return { minX, minY, maxX, maxY };
394
+ });
395
+ for (let i = 0; i < aPolys.length; i++) {
396
+ for (let j = 0; j < bPolys.length; j++) {
397
+ const A = aPolys[i], B = bPolys[j];
398
+ const aa = aabbsA[i], bb = aabbsB[j];
399
+ if (!A || !B || !aa || !bb) continue;
400
+ if (aa.maxX <= bb.minX + SEP_EPS || bb.maxX <= aa.minX + SEP_EPS || aa.maxY <= bb.minY + SEP_EPS || bb.maxY <= aa.minY + SEP_EPS) continue;
401
+ if (convexIntersects(A, B)) return true;
402
+ }
403
+ }
404
+ return false;
405
+ }
406
+
407
+ function polysAABB$1(polys) {
408
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
409
+ for (const poly of polys) {
410
+ for (const p of poly) {
411
+ if (p.x < minX) minX = p.x;
412
+ if (p.y < minY) minY = p.y;
413
+ if (p.x > maxX) maxX = p.x;
414
+ if (p.y > maxY) maxY = p.y;
415
+ }
416
+ }
417
+ const width = Math.max(1, maxX - minX);
418
+ const height = Math.max(1, maxY - minY);
419
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY }, width, height, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
420
+ }
421
+ function boundsOfShapes(shapes) {
422
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
423
+ for (const poly of shapes) {
424
+ for (const p of poly) {
425
+ if (p.x < minX) minX = p.x;
426
+ if (p.y < minY) minY = p.y;
427
+ if (p.x > maxX) maxX = p.x;
428
+ if (p.y > maxY) maxY = p.y;
429
+ }
430
+ }
431
+ const width = Math.max(0, maxX - minX);
432
+ const height = Math.max(0, maxY - minY);
433
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY }, width, height };
434
+ }
435
+ function boundsOfPrimitive(bp) {
436
+ return boundsOfShapes(bp.shape);
437
+ }
438
+ function boundsOfComposite(bp, primitiveLookup) {
439
+ if (bp.shape && bp.shape.length) return boundsOfShapes(bp.shape);
440
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
441
+ for (const part of bp.parts) {
442
+ const prim = primitiveLookup(part.kind);
443
+ if (!prim) continue;
444
+ const b = boundsOfPrimitive(prim);
445
+ const pMinX = part.offset.x + b.min.x;
446
+ const pMinY = part.offset.y + b.min.y;
447
+ const pMaxX = part.offset.x + b.max.x;
448
+ const pMaxY = part.offset.y + b.max.y;
449
+ if (pMinX < minX) minX = pMinX;
450
+ if (pMinY < minY) minY = pMinY;
451
+ if (pMaxX > maxX) maxX = pMaxX;
452
+ if (pMaxY > maxY) maxY = pMaxY;
453
+ }
454
+ const width = Math.max(0, maxX - minX);
455
+ const height = Math.max(0, maxY - minY);
456
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY }, width, height };
457
+ }
458
+ function boundsOfBlueprint(bp, primitiveLookup) {
459
+ if ("parts" in bp) return boundsOfComposite(bp, primitiveLookup);
460
+ return boundsOfPrimitive(bp);
461
+ }
462
+
463
+ const GRID_PX = CONFIG.layout.grid.stepPx;
464
+ function piecePolysAt(bp, bb, tl) {
465
+ const ox = tl.x - bb.min.x;
466
+ const oy = tl.y - bb.min.y;
467
+ const polys = "shape" in bp && bp.shape ? bp.shape : [];
468
+ return polys.map((poly) => poly.map((p) => ({ x: p.x + ox, y: p.y + oy })));
469
+ }
470
+ function computeSupportOffsets(bp, bb) {
471
+ const cx = bb.min.x + bb.width / 2;
472
+ const cy = bb.min.y + bb.height / 2;
473
+ const polys = "shape" in bp && bp.shape && bp.shape.length ? bp.shape : [[
474
+ { x: bb.min.x, y: bb.min.y },
475
+ { x: bb.min.x + bb.width, y: bb.min.y },
476
+ { x: bb.min.x + bb.width, y: bb.min.y + bb.height },
477
+ { x: bb.min.x, y: bb.min.y + bb.height }
478
+ ]];
479
+ const offs = [];
480
+ for (const poly of polys) for (const v of poly) offs.push({ x: v.x - cx, y: v.y - cy });
481
+ return offs;
482
+ }
483
+ function clampTopLeftBySupport(tlx, tly, d, layout, target, pointerInsideCenter) {
484
+ const cx0 = tlx + d.aabb.width / 2;
485
+ const cy0 = tly + d.aabb.height / 2;
486
+ const vx = cx0 - layout.cx;
487
+ const vy = cy0 - layout.cy;
488
+ const r = Math.hypot(vx, vy);
489
+ if (r === 0) return { x: tlx, y: tly };
490
+ const ux = vx / r, uy = vy / r;
491
+ let h_out = -Infinity, h_in = -Infinity;
492
+ for (const o of d.support) {
493
+ const outProj = o.x * ux + o.y * uy;
494
+ if (outProj > h_out) h_out = outProj;
495
+ const inProj = -(o.x * ux + o.y * uy);
496
+ if (inProj > h_in) h_in = inProj;
497
+ }
498
+ if (target === "workspace") {
499
+ const [rIn, rOut] = layout.bands.workspace;
500
+ const minR = pointerInsideCenter ? 0 : rIn + h_in;
501
+ const maxR = rOut - h_out;
502
+ const rClamped = Math.min(Math.max(r, minR), maxR);
503
+ if (Math.abs(rClamped - r) < 1e-6) return { x: tlx, y: tly };
504
+ const newCx = layout.cx + rClamped * ux;
505
+ const newCy = layout.cy + rClamped * uy;
506
+ return { x: newCx - d.aabb.width / 2, y: newCy - d.aabb.height / 2 };
507
+ } else {
508
+ const rOut = layout.bands.silhouette[1];
509
+ const maxR = rOut - h_out;
510
+ if (r <= maxR) return { x: tlx, y: tly };
511
+ const newCx = layout.cx + maxR * ux;
512
+ const newCy = layout.cy + maxR * uy;
513
+ return { x: newCx - d.aabb.width / 2, y: newCy - d.aabb.height / 2 };
514
+ }
515
+ }
516
+ function igcd$1(a, b) {
517
+ a = Math.round(Math.abs(a));
518
+ b = Math.round(Math.abs(b));
519
+ while (b) [a, b] = [b, a % b];
520
+ return a || 1;
521
+ }
522
+ function inferUnitFromPolys$1(polys) {
523
+ let g = 0;
524
+ for (const poly of polys) {
525
+ for (let i = 0; i < poly.length; i++) {
526
+ const a = poly[i], b = poly[(i + 1) % poly.length];
527
+ if (!a || !b) continue;
528
+ const dx = Math.round(Math.abs(b.x - a.x));
529
+ const dy = Math.round(Math.abs(b.y - a.y));
530
+ if (dx) g = g ? igcd$1(g, dx) : dx;
531
+ if (dy) g = g ? igcd$1(g, dy) : dy;
532
+ }
533
+ }
534
+ return g || 1;
535
+ }
536
+ function placeSilhouetteGridAlignedAsPolys(polys, S, rectCenter) {
537
+ if (!polys || polys.length === 0) return [];
538
+ const a = polysAABB$1(polys);
539
+ const cx0 = (a.min.x + a.max.x) / 2;
540
+ const cy0 = (a.min.y + a.max.y) / 2;
541
+ const cx = Math.round(rectCenter.cx / GRID_PX) * GRID_PX;
542
+ const cy = Math.round(rectCenter.cy / GRID_PX) * GRID_PX;
543
+ const tx = cx - S * cx0;
544
+ const ty = cy - S * cy0;
545
+ const stx = Math.round(tx / GRID_PX) * GRID_PX;
546
+ const sty = Math.round(ty / GRID_PX) * GRID_PX;
547
+ return polys.map((poly) => poly.map((p) => ({ x: S * p.x + stx, y: S * p.y + sty })));
548
+ }
549
+ function gridNodesInAABB(min, max) {
550
+ const out = [];
551
+ const x0 = Math.ceil(min.x / GRID_PX) * GRID_PX;
552
+ const y0 = Math.ceil(min.y / GRID_PX) * GRID_PX;
553
+ for (let y = y0; y <= max.y; y += GRID_PX) {
554
+ for (let x = x0; x <= max.x; x += GRID_PX) out.push({ x, y });
555
+ }
556
+ return out;
557
+ }
558
+ function filterNodesToBandAndSector(nodes, layout, band, sector) {
559
+ const [rIn, rOut] = layout.bands[band];
560
+ const out = [];
561
+ for (const n of nodes) {
562
+ const dx = n.x - layout.cx, dy = n.y - layout.cy;
563
+ const r = Math.hypot(dx, dy);
564
+ if (r < rIn || r > rOut) continue;
565
+ if (sector) {
566
+ let theta = Math.atan2(dy, dx);
567
+ if (layout.mode === "circle") {
568
+ if (theta < -Math.PI / 2) theta += 2 * Math.PI;
569
+ } else {
570
+ if (theta < Math.PI) theta += 2 * Math.PI;
571
+ }
572
+ if (theta < sector.start || theta >= sector.end) continue;
573
+ }
574
+ out.push(n);
575
+ }
576
+ return out;
577
+ }
578
+ function filterNodesInPolys(nodes, polys, pointInPolyFn = pointInPolygon) {
579
+ const out = [];
580
+ node: for (const n of nodes) {
581
+ for (const poly of polys) {
582
+ if (pointInPolyFn(n, poly)) {
583
+ out.push(n);
584
+ continue node;
585
+ }
586
+ }
587
+ }
588
+ return out;
589
+ }
590
+ function innerRingNodes(layout) {
591
+ const pad = 6;
592
+ const min = { x: layout.cx - layout.innerR, y: layout.cy - layout.innerR - pad };
593
+ const max = { x: layout.cx + layout.innerR, y: layout.cy + layout.innerR + pad };
594
+ const nodes = gridNodesInAABB(min, max);
595
+ const out = [];
596
+ for (const n of nodes) {
597
+ const dx = n.x - layout.cx, dy = n.y - layout.cy;
598
+ const r = Math.hypot(dx, dy);
599
+ if (r < layout.innerR) out.push(n);
600
+ }
601
+ return out;
602
+ }
603
+ const anchorsDiameterToPx = (anchorsDiag, gridPx = GRID_PX) => anchorsDiag * Math.SQRT2 * gridPx;
604
+
605
+ function igcd(a, b) {
606
+ a = Math.round(Math.abs(a));
607
+ b = Math.round(Math.abs(b));
608
+ while (b) [a, b] = [b, a % b];
609
+ return a || 1;
610
+ }
611
+ function inferUnitFromPolys(polys) {
612
+ let g = 0;
613
+ for (const poly of polys) {
614
+ for (let i = 0; i < poly.length; i++) {
615
+ const a = poly[i], b = poly[(i + 1) % poly.length];
616
+ if (!a || !b) continue;
617
+ const dx = Math.round(Math.abs(b.x - a.x));
618
+ const dy = Math.round(Math.abs(b.y - a.y));
619
+ if (dx) g = g ? igcd(g, dx) : dx;
620
+ if (dy) g = g ? igcd(g, dy) : dy;
621
+ }
622
+ }
623
+ return g || 1;
624
+ }
625
+ function polysAABB(polys) {
626
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
627
+ for (const poly of polys) for (const p of poly) {
628
+ if (p.x < minX) minX = p.x;
629
+ if (p.y < minY) minY = p.y;
630
+ if (p.x > maxX) maxX = p.x;
631
+ if (p.y > maxY) maxY = p.y;
632
+ }
633
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
634
+ return { minX, minY, maxX, maxY, cx, cy };
635
+ }
636
+ function solveRadii(params) {
637
+ const {
638
+ n,
639
+ layoutMode,
640
+ target,
641
+ qsMaxSlots,
642
+ primitivesSlots,
643
+ gridPx = CONFIG.layout.grid.stepPx,
644
+ reqWorkspaceDiamAnchors = CONFIG.layout.constraints.workspaceDiamAnchors,
645
+ reqQuickstashDiamAnchors = CONFIG.layout.constraints.quickstashDiamAnchors,
646
+ reqPrimitiveDiamAnchors = CONFIG.layout.constraints.primitiveDiamAnchors
647
+ } = params;
648
+ const allPolys = (params.masks ?? []).flat();
649
+ const rawUnit = allPolys.length ? inferUnitFromPolys(allPolys) : 0;
650
+ const unitScaleS = rawUnit ? 2 * gridPx / rawUnit : 1;
651
+ let Rshape_px_max = 0;
652
+ if (params.masks && params.masks.length) {
653
+ for (const sectorPolys of params.masks) {
654
+ if (!sectorPolys?.length) continue;
655
+ const a = polysAABB(sectorPolys);
656
+ const { cx, cy } = a;
657
+ for (const poly of sectorPolys) {
658
+ for (const p of poly) {
659
+ const rx = (p.x - cx) * unitScaleS;
660
+ const ry = (p.y - cy) * unitScaleS;
661
+ const r = Math.hypot(rx, ry);
662
+ if (r > Rshape_px_max) Rshape_px_max = r;
663
+ }
664
+ }
665
+ }
666
+ }
667
+ const sweep = layoutMode === "circle" ? Math.PI * 2 : Math.PI;
668
+ const dOut = sweep / Math.max(1, n);
669
+ const dIn = sweep / Math.max(1, qsMaxSlots);
670
+ const dPrim = sweep / Math.max(1, primitivesSlots);
671
+ const sinOut = Math.sin(dOut / 2);
672
+ const sinIn = Math.sin(dIn / 2);
673
+ const sinPrim = Math.sin(dPrim / 2);
674
+ const D_ws = anchorsDiameterToPx(reqWorkspaceDiamAnchors, gridPx);
675
+ const D_bp = anchorsDiameterToPx(reqQuickstashDiamAnchors, gridPx);
676
+ const D_prim = anchorsDiameterToPx(reqPrimitiveDiamAnchors, gridPx);
677
+ const innerR_min_bp = D_bp * (0.5 + 1 / (2 * Math.max(1e-9, sinIn)));
678
+ const innerR_min_prim = D_prim * (0.5 + 1 / (2 * Math.max(1e-9, sinPrim)));
679
+ const innerR_min_ws = D_ws / (2 * Math.max(1e-9, sinOut)) - D_ws / 2;
680
+ const innerR_min = Math.max(innerR_min_bp, innerR_min_prim, innerR_min_ws);
681
+ const Tw_min = D_ws;
682
+ const Tp_min = target === "workspace" ? 2 * Rshape_px_max : 0;
683
+ const dr_min = Tw_min + Tp_min;
684
+ const outerR_min = innerR_min + dr_min;
685
+ return { innerR_min, dr_min, outerR_min, Tw_min, Tp_min, Rshape_px_max };
686
+ }
687
+ function optimizeBandSplit(args) {
688
+ const { outerRViewportBound, innerR_min, Tw_min, Tp_min, Rshape_px_max, D_ws_px } = args;
689
+ const dr_min = Tw_min + Tp_min;
690
+ if (outerRViewportBound < innerR_min + dr_min) {
691
+ const outerR2 = innerR_min + dr_min;
692
+ return { innerR: innerR_min, Tw: Tw_min, Tp: Tp_min, outerR: outerR2 };
693
+ }
694
+ const outerR = outerRViewportBound;
695
+ const innerR = innerR_min;
696
+ const dr = outerR - innerR;
697
+ const slack = Math.max(0, dr - dr_min);
698
+ const wWork = 1;
699
+ const wSil = 1 + Rshape_px_max / Math.max(1e-9, D_ws_px);
700
+ const addWork = slack * (wWork / (wWork + wSil));
701
+ const Tw = Tw_min + addWork;
702
+ const Tp = dr - Tw;
703
+ return { innerR, Tw, Tp, outerR };
704
+ }
705
+ function solveLogicalBox(params) {
706
+ const { layoutMode, layoutPadPx } = params;
707
+ const { outerR_min } = solveRadii(params);
708
+ if (layoutMode === "circle") {
709
+ const LOGICAL_W = 2 * (outerR_min + layoutPadPx);
710
+ return { LOGICAL_W, LOGICAL_H: LOGICAL_W };
711
+ } else {
712
+ const LOGICAL_W = 2 * (outerR_min + layoutPadPx);
713
+ return { LOGICAL_W, LOGICAL_H: LOGICAL_W / 2 };
714
+ }
715
+ }
716
+
717
+ function computeCircleLayout(viewport, n, sectorIds, mode = "circle", target = "workspace", extras) {
718
+ const pad = CONFIG.layout.paddingPx;
719
+ const cx = viewport.w / 2;
720
+ const cy = mode === "semicircle" ? viewport.h : viewport.h / 2;
721
+ const maxRVertical = mode === "semicircle" ? viewport.h - pad : cy;
722
+ const outerRViewportBound = Math.min(cx, maxRVertical) - pad;
723
+ const sol = solveRadii({
724
+ n,
725
+ layoutMode: mode,
726
+ target,
727
+ qsMaxSlots: extras.qsMaxSlots,
728
+ primitivesSlots: extras.primitivesSlots,
729
+ masks: extras.masks ?? []
730
+ });
731
+ let innerR, outerR, bands;
732
+ if (target === "workspace") {
733
+ const D_ws_px = anchorsDiameterToPx(CONFIG.layout.constraints.workspaceDiamAnchors);
734
+ const opt = optimizeBandSplit({
735
+ outerRViewportBound,
736
+ innerR_min: sol.innerR_min,
737
+ Tw_min: sol.Tw_min,
738
+ Tp_min: sol.Tp_min,
739
+ Rshape_px_max: sol.Rshape_px_max,
740
+ D_ws_px
741
+ });
742
+ innerR = opt.innerR;
743
+ outerR = opt.outerR;
744
+ bands = {
745
+ workspace: [opt.innerR, opt.innerR + opt.Tw],
746
+ silhouette: [opt.innerR + opt.Tw, opt.outerR]
747
+ };
748
+ } else {
749
+ outerR = Math.max(outerRViewportBound, sol.outerR_min);
750
+ innerR = Math.min(sol.innerR_min, outerR);
751
+ if (outerR < sol.outerR_min) {
752
+ outerR = sol.outerR_min;
753
+ innerR = sol.innerR_min;
754
+ }
755
+ bands = { silhouette: [innerR, outerR], workspace: [innerR, outerR] };
756
+ }
757
+ const sweep = mode === "circle" ? Math.PI * 2 : Math.PI;
758
+ const sweepStart = mode === "circle" ? -Math.PI / 2 : Math.PI;
759
+ const sweepEnd = sweepStart + sweep;
760
+ const step = sweep / n;
761
+ const sectors = [];
762
+ for (let i = 0; i < n; i++) {
763
+ const id = sectorIds[i] ?? String(i);
764
+ const start = sweepStart + i * step;
765
+ const end = start + step;
766
+ const mid = (start + end) / 2;
767
+ sectors.push({ id, index: i, start, end, mid });
768
+ }
769
+ return { mode, cx, cy, innerR, outerR, sectors, bands, sweepStart, sweepEnd };
770
+ }
771
+ function wedgePath(cx, cy, innerR, outerR, start, end) {
772
+ const largeArc = end - start > Math.PI ? 1 : 0;
773
+ const x0 = cx + innerR * Math.cos(start);
774
+ const y0 = cy + innerR * Math.sin(start);
775
+ const x1 = cx + outerR * Math.cos(start);
776
+ const y1 = cy + outerR * Math.sin(start);
777
+ const x2 = cx + outerR * Math.cos(end);
778
+ const y2 = cy + outerR * Math.sin(end);
779
+ const x3 = cx + innerR * Math.cos(end);
780
+ const y3 = cy + innerR * Math.sin(end);
781
+ return [
782
+ `M ${x0} ${y0}`,
783
+ `L ${x1} ${y1}`,
784
+ `A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2}`,
785
+ `L ${x3} ${y3}`,
786
+ `A ${innerR} ${innerR} 0 ${largeArc} 0 ${x0} ${y0}`,
787
+ "Z"
788
+ ].join(" ");
789
+ }
790
+ function sectorAtPoint(x, y, layout, band) {
791
+ const dx = x - layout.cx;
792
+ const dy = y - layout.cy;
793
+ const r = Math.hypot(dx, dy);
794
+ const [rIn, rOut] = band ? layout.bands[band] : [layout.innerR, layout.outerR];
795
+ if (r < rIn || r > rOut) return void 0;
796
+ let theta = Math.atan2(dy, dx);
797
+ if (layout.mode === "circle") {
798
+ if (theta < -Math.PI / 2) theta += 2 * Math.PI;
799
+ } else {
800
+ if (theta < Math.PI) theta += 2 * Math.PI;
801
+ }
802
+ for (const s of layout.sectors) {
803
+ if (theta >= s.start && theta < s.end) return s.id;
804
+ }
805
+ return layout.sectors.at(-1)?.id;
806
+ }
807
+ function rectForBand(layout, sector, band, pad = 0.85) {
808
+ const [rIn, rOut] = layout.bands[band];
809
+ const rMid = (rIn + rOut) / 2;
810
+ const dr = rOut - rIn;
811
+ const arc = (sector.end - sector.start) * rMid * 0.9;
812
+ const w = arc * pad;
813
+ const h = dr * pad;
814
+ const cx = layout.cx + rMid * Math.cos(sector.mid);
815
+ const cy = layout.cy + rMid * Math.sin(sector.mid);
816
+ return { cx, cy, w, h };
817
+ }
818
+
819
+ function generateEdgeStrokePaths(poly) {
820
+ const strokePaths = [];
821
+ for (let i = 0; i < poly.length; i++) {
822
+ const current = poly[i];
823
+ const next = poly[(i + 1) % poly.length];
824
+ if (current && next) {
825
+ strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
826
+ }
827
+ }
828
+ return strokePaths;
829
+ }
830
+ function edgeToUnitSegments$1(start, end, gridSize) {
831
+ const segments = [];
832
+ const startGrid = {
833
+ x: Math.round(start.x / gridSize),
834
+ y: Math.round(start.y / gridSize)
835
+ };
836
+ const endGrid = {
837
+ x: Math.round(end.x / gridSize),
838
+ y: Math.round(end.y / gridSize)
839
+ };
840
+ const dx = endGrid.x - startGrid.x;
841
+ const dy = endGrid.y - startGrid.y;
842
+ if (dx === 0 && dy === 0) return [];
843
+ const steps = Math.max(Math.abs(dx), Math.abs(dy));
844
+ const stepX = dx / steps;
845
+ const stepY = dy / steps;
846
+ for (let i = 0; i < steps; i++) {
847
+ const aX = Math.round(startGrid.x + i * stepX);
848
+ const aY = Math.round(startGrid.y + i * stepY);
849
+ const bX = Math.round(startGrid.x + (i + 1) * stepX);
850
+ const bY = Math.round(startGrid.y + (i + 1) * stepY);
851
+ segments.push({
852
+ a: { x: aX, y: aY },
853
+ b: { x: bX, y: bY }
854
+ });
855
+ }
856
+ return segments;
857
+ }
858
+ function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
859
+ let blueprint;
860
+ try {
861
+ blueprint = getBlueprint(piece.blueprintId);
862
+ } catch (error) {
863
+ console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
864
+ return [];
865
+ }
866
+ if (!blueprint?.shape) {
867
+ return [];
868
+ }
869
+ const poly = blueprint.shape[polyIndex];
870
+ if (!poly) return [];
871
+ const gridSize = CONFIG.layout.grid.stepPx;
872
+ const bb = boundsOfBlueprint(blueprint, getPrimitive);
873
+ const ox = piece.pos.x - bb.min.x;
874
+ const oy = piece.pos.y - bb.min.y;
875
+ const translatedPoly = poly.map((vertex) => ({
876
+ x: vertex.x + ox,
877
+ y: vertex.y + oy
878
+ }));
879
+ const hiddenEdges = [];
880
+ for (let i = 0; i < translatedPoly.length; i++) {
881
+ const current = translatedPoly[i];
882
+ const next = translatedPoly[(i + 1) % translatedPoly.length];
883
+ if (!current || !next) {
884
+ hiddenEdges.push(false);
885
+ continue;
886
+ }
887
+ const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
888
+ let isTouching = false;
889
+ if (piece.blueprintId.startsWith("comp:")) {
890
+ for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
891
+ if (otherPolyIndex === polyIndex) continue;
892
+ const otherPoly = blueprint.shape[otherPolyIndex];
893
+ if (!otherPoly) continue;
894
+ const otherTranslatedPoly = otherPoly.map((vertex) => ({
895
+ x: vertex.x + ox,
896
+ y: vertex.y + oy
897
+ }));
898
+ for (let j = 0; j < otherTranslatedPoly.length; j++) {
899
+ const otherCurrent = otherTranslatedPoly[j];
900
+ const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
901
+ if (!otherCurrent || !otherNext) continue;
902
+ const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
903
+ for (const edgeSeg of edgeSegments) {
904
+ const isShared = otherEdgeSegments.some(
905
+ (otherSeg) => (
906
+ // Check if segments share the same endpoints (identical or reversed)
907
+ edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
908
+ )
909
+ );
910
+ if (isShared) {
911
+ isTouching = true;
912
+ break;
913
+ }
914
+ }
915
+ if (isTouching) break;
916
+ }
917
+ if (isTouching) break;
918
+ }
919
+ }
920
+ if (!isTouching && CONFIG.game.hideTouchingBorders) {
921
+ for (const otherPiece of allPiecesInSector) {
922
+ if (otherPiece.id === piece.id) continue;
923
+ const otherBlueprint = getBlueprint(otherPiece.blueprintId);
924
+ if (!otherBlueprint?.shape) continue;
925
+ const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
926
+ const otherOx = otherPiece.pos.x - otherBb.min.x;
927
+ const otherOy = otherPiece.pos.y - otherBb.min.y;
928
+ for (const otherPoly of otherBlueprint.shape) {
929
+ const otherTranslatedPoly = otherPoly.map((vertex) => ({
930
+ x: vertex.x + otherOx,
931
+ y: vertex.y + otherOy
932
+ }));
933
+ for (let j = 0; j < otherTranslatedPoly.length; j++) {
934
+ const otherCurrent = otherTranslatedPoly[j];
935
+ const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
936
+ if (!otherCurrent || !otherNext) continue;
937
+ const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
938
+ for (const edgeSeg of edgeSegments) {
939
+ const isShared = otherEdgeSegments.some(
940
+ (otherSeg) => (
941
+ // Check if segments share the same endpoints (identical or reversed)
942
+ edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
943
+ )
944
+ );
945
+ if (isShared) {
946
+ isTouching = true;
947
+ break;
948
+ }
949
+ }
950
+ if (isTouching) break;
951
+ }
952
+ if (isTouching) break;
953
+ }
954
+ if (isTouching) break;
955
+ }
956
+ }
957
+ hiddenEdges.push(isTouching);
958
+ }
959
+ return hiddenEdges;
960
+ }
961
+ function shouldUseSelectiveBorders(blueprintId) {
962
+ return (CONFIG.game.hideTouchingBorders);
963
+ }
964
+
965
+ function pathD(poly) {
966
+ return `M ${poly.map((pt) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
967
+ }
968
+ function BoardView(props) {
969
+ const {
970
+ controller,
971
+ layout,
972
+ viewBox,
973
+ width,
974
+ height,
975
+ badgeR,
976
+ badgeCenter,
977
+ placedSilBySector,
978
+ anchorDots,
979
+ pieces,
980
+ clickMode,
981
+ draggingId,
982
+ selectedPieceId,
983
+ dragInvalid,
984
+ lockedPieceId,
985
+ svgRef,
986
+ setPieceRef,
987
+ onPiecePointerDown,
988
+ onBlueprintPointerDown,
989
+ onRootPointerDown,
990
+ onPointerMove,
991
+ onPointerUp,
992
+ onCenterBadgePointerDown
993
+ } = props;
994
+ const VW = viewBox.w, VH = viewBox.h;
995
+ const centerView = controller.state.blueprintView;
996
+ const bps = centerView === "primitives" ? controller.state.primitives : controller.state.quickstash;
997
+ const QS_SLOTS = controller.state.cfg.maxQuickstashSlots;
998
+ const PRIM_SLOTS = controller.state.primitives.length;
999
+ const slotsForView = centerView === "primitives" ? Math.max(1, PRIM_SLOTS) : Math.max(1, QS_SLOTS);
1000
+ const sweep = layout.mode === "circle" ? Math.PI * 2 : Math.PI;
1001
+ const delta = sweep / slotsForView;
1002
+ const start = layout.mode === "circle" ? -Math.PI / 2 : Math.PI;
1003
+ const blueprintTheta = (i) => start + (i + 0.5) * delta;
1004
+ const anchorsDiameterToPx = (anchorsDiag, gridPx = CONFIG.layout.grid.stepPx) => anchorsDiag * Math.SQRT2 * gridPx;
1005
+ const reqAnchors = centerView === "primitives" ? CONFIG.layout.constraints.primitiveDiamAnchors : CONFIG.layout.constraints.quickstashDiamAnchors;
1006
+ const D_slot = anchorsDiameterToPx(reqAnchors);
1007
+ const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
1008
+ const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
1009
+ const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
1010
+ const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
1011
+ const renderBlueprintGlyph = (bp, bx, by) => {
1012
+ const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
1013
+ const cx = bb.min.x + bb.width / 2;
1014
+ const cy = bb.min.y + bb.height / 2;
1015
+ return /* @__PURE__ */ React.createElement(
1016
+ "g",
1017
+ {
1018
+ key: bp.id,
1019
+ transform: `translate(${bx}, ${by}) scale(1) translate(${-cx}, ${-cy})`
1020
+ },
1021
+ ("shape" in bp ? bp.shape : []).map((poly, idx) => /* @__PURE__ */ React.createElement(
1022
+ "path",
1023
+ {
1024
+ key: idx,
1025
+ d: pathD(poly),
1026
+ fill: CONFIG.color.blueprint.fill,
1027
+ opacity: CONFIG.opacity.blueprint,
1028
+ stroke: "none",
1029
+ strokeWidth: 0,
1030
+ pointerEvents: "visiblePainted",
1031
+ style: { cursor: "pointer" },
1032
+ onPointerDown: (e) => onBlueprintPointerDown(e, bp, { bx, by, cx, cy })
1033
+ }
1034
+ ))
1035
+ );
1036
+ };
1037
+ return /* @__PURE__ */ React.createElement(
1038
+ "svg",
1039
+ {
1040
+ ref: svgRef,
1041
+ width,
1042
+ height,
1043
+ viewBox: `0 0 ${VW} ${VH}`,
1044
+ preserveAspectRatio: "xMidYMid meet",
1045
+ onPointerMove,
1046
+ onPointerUp,
1047
+ onPointerDown: (e) => {
1048
+ onRootPointerDown(e);
1049
+ },
1050
+ style: { background: "#fff", touchAction: "none", userSelect: "none" }
1051
+ },
1052
+ layout.sectors.map((s, i) => {
1053
+ const done = !!controller.state.sectors[s.id].completedAt;
1054
+ const baseSil = i % 2 ? CONFIG.color.bands.silhouette.fillOdd : CONFIG.color.bands.silhouette.fillEven;
1055
+ const baseWork = i % 2 ? CONFIG.color.bands.workspace.fillOdd : CONFIG.color.bands.workspace.fillEven;
1056
+ const sil = layout.bands.silhouette;
1057
+ const work = layout.bands.workspace;
1058
+ return /* @__PURE__ */ React.createElement("g", { key: `bands-${s.id}` }, controller.state.cfg.target === "workspace" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("path", { d: wedgePath(layout.cx, layout.cy, sil[0], sil[1], s.start, s.end), fill: baseSil, stroke: CONFIG.color.bands.silhouette.stroke, strokeWidth: CONFIG.size.stroke.bandPx, pointerEvents: "none" }), /* @__PURE__ */ React.createElement("path", { d: wedgePath(layout.cx, layout.cy, work[0], work[1], s.start, s.end), fill: done ? CONFIG.color.completion.fill : baseWork, stroke: done ? CONFIG.color.completion.stroke : CONFIG.color.bands.workspace.stroke, strokeWidth: CONFIG.size.stroke.bandPx, pointerEvents: "none" })) : /* @__PURE__ */ React.createElement("path", { d: wedgePath(layout.cx, layout.cy, sil[0], sil[1], s.start, s.end), fill: done ? CONFIG.color.completion.fill : baseSil, stroke: done ? CONFIG.color.completion.stroke : CONFIG.color.bands.silhouette.stroke, strokeWidth: CONFIG.size.stroke.bandPx, pointerEvents: "none" }));
1059
+ }),
1060
+ pieces.sort((a, b) => {
1061
+ if (draggingId === a.id) return 1;
1062
+ if (draggingId === b.id) return -1;
1063
+ return 0;
1064
+ }).map((p) => {
1065
+ const bp = controller.getBlueprint(p.blueprintId);
1066
+ const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
1067
+ const isDragging = draggingId === p.id;
1068
+ const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1069
+ const isConnectivityLocked = lockedPieceId === p.id;
1070
+ const isSelected = selectedPieceId === p.id;
1071
+ const isCarriedInvalid = isDragging && dragInvalid;
1072
+ const translateX = p.x - bb.min.x;
1073
+ const translateY = p.y - bb.min.y;
1074
+ return /* @__PURE__ */ React.createElement("g", { key: p.id, ref: setPieceRef(p.id), transform: `translate(${translateX}, ${translateY})`, style: { cursor: locked ? "default" : clickMode ? "pointer" : "grab" }, pointerEvents: clickMode && isDragging ? "none" : "auto" }, ("shape" in bp ? bp.shape : []).map((poly, idx) => {
1075
+ shouldUseSelectiveBorders(p.blueprintId);
1076
+ return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1077
+ "path",
1078
+ {
1079
+ d: pathD(poly),
1080
+ fill: isConnectivityLocked ? CONFIG.color.piece.invalidFill : isCarriedInvalid ? CONFIG.color.piece.invalidFill : isDragging ? CONFIG.color.piece.draggingFill : CONFIG.color.piece.validFill,
1081
+ opacity: isConnectivityLocked ? CONFIG.opacity.piece.invalid : isCarriedInvalid ? CONFIG.opacity.piece.invalid : isDragging ? CONFIG.opacity.piece.dragging : locked ? CONFIG.opacity.piece.locked : CONFIG.opacity.piece.normal,
1082
+ stroke: "none",
1083
+ onPointerDown: (e) => onPiecePointerDown(e, p)
1084
+ }
1085
+ ), ((
1086
+ // For pieces with selective borders: render individual edge strokes with edge detection
1087
+ (() => {
1088
+ const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1089
+ const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1090
+ const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1091
+ const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1092
+ const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1093
+ let wasTouchingDraggedPiece;
1094
+ if (p.blueprintId.startsWith("comp:")) {
1095
+ const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1096
+ const externalHiddenEdges = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1097
+ wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1098
+ } else {
1099
+ wasTouchingDraggedPiece = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1100
+ }
1101
+ return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1102
+ const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1103
+ let isHidden;
1104
+ if (isDragging && p.blueprintId.startsWith("comp:")) {
1105
+ const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1106
+ isHidden = internalHiddenEdges[strokeIdx] || false;
1107
+ } else {
1108
+ isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1109
+ }
1110
+ return /* @__PURE__ */ React.createElement(
1111
+ "path",
1112
+ {
1113
+ key: `stroke-${idx}-${strokeIdx}`,
1114
+ d: strokePath,
1115
+ fill: "none",
1116
+ stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1117
+ strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1118
+ onPointerDown: (e) => onPiecePointerDown(e, p)
1119
+ }
1120
+ );
1121
+ });
1122
+ })()
1123
+ ) ));
1124
+ }));
1125
+ }),
1126
+ layout.sectors.map((s) => {
1127
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
1128
+ if (!placedPolys.length) return null;
1129
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-${s.id}`, pointerEvents: "none" }, placedPolys.map((poly, i) => /* @__PURE__ */ React.createElement("path", { key: i, d: pathD(poly), fill: CONFIG.color.silhouetteMask, opacity: CONFIG.opacity.silhouetteMask })));
1130
+ }),
1131
+ anchorDots.map(({ sectorId, valid, invalid }) => {
1132
+ const isInnerRing = sectorId === "inner-ring";
1133
+ return /* @__PURE__ */ React.createElement("g", { key: `anchors-${sectorId}`, pointerEvents: "none" }, invalid.map((p, i) => /* @__PURE__ */ React.createElement("circle", { key: `inv-${i}`, cx: p.x, cy: p.y, r: CONFIG.size.anchorRadiusPx.invalid, fill: CONFIG.color.anchors.invalid, opacity: CONFIG.opacity.anchors.invalid })), valid.map((p, i) => /* @__PURE__ */ React.createElement(
1134
+ "circle",
1135
+ {
1136
+ key: `val-${i}`,
1137
+ cx: p.x,
1138
+ cy: p.y,
1139
+ r: isInnerRing ? CONFIG.size.anchorRadiusPx.invalid : CONFIG.size.anchorRadiusPx.valid,
1140
+ fill: isInnerRing ? CONFIG.color.anchors.invalid : CONFIG.color.anchors.valid,
1141
+ opacity: isInnerRing ? CONFIG.opacity.anchors.invalid : CONFIG.opacity.anchors.valid
1142
+ }
1143
+ )));
1144
+ }),
1145
+ (() => {
1146
+ const isPrep = controller.state.cfg.mode === "prep";
1147
+ const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
1148
+ const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
1149
+ return /* @__PURE__ */ React.createElement(
1150
+ "g",
1151
+ {
1152
+ transform: `translate(${badgeCenter.x}, ${badgeCenter.y})`,
1153
+ style: { cursor: isClickable ? "pointer" : "default" },
1154
+ onPointerDown: isClickable ? onCenterBadgePointerDown : void 0
1155
+ },
1156
+ /* @__PURE__ */ React.createElement(
1157
+ "circle",
1158
+ {
1159
+ r: badgeR,
1160
+ fill: isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc",
1161
+ opacity: isSubmitEnabled ? 1 : 0.5
1162
+ }
1163
+ ),
1164
+ /* @__PURE__ */ React.createElement(
1165
+ "text",
1166
+ {
1167
+ textAnchor: "middle",
1168
+ dominantBaseline: "middle",
1169
+ fontSize: CONFIG.size.badgeFontPx,
1170
+ fill: isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888",
1171
+ pointerEvents: "none"
1172
+ },
1173
+ isPrep ? "Submit" : controller.state.blueprintView
1174
+ )
1175
+ );
1176
+ })(),
1177
+ bps.map((bp, i) => {
1178
+ const theta = blueprintTheta(i);
1179
+ const bx = layout.cx + blueprintRingR * Math.cos(theta);
1180
+ const by = layout.cy + blueprintRingR * Math.sin(theta);
1181
+ return renderBlueprintGlyph(bp, bx, by);
1182
+ }),
1183
+ controller.state.endedAt && /* @__PURE__ */ React.createElement("g", { pointerEvents: "none" }, /* @__PURE__ */ React.createElement("circle", { cx: layout.cx, cy: layout.cy, r: layout.outerR - 3, fill: "none", stroke: CONFIG.color.piece.allGreenStroke, strokeWidth: CONFIG.size.stroke.allGreenStrokePx, opacity: 0 }, /* @__PURE__ */ React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "160ms", fill: "freeze" }), /* @__PURE__ */ React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", begin: "560ms", dur: "520ms", fill: "freeze" })))
1184
+ );
1185
+ }
1186
+
1187
+ function aabb(polys) {
1188
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1189
+ for (const poly of polys) for (const p of poly) {
1190
+ if (p.x < minX) minX = p.x;
1191
+ if (p.y < minY) minY = p.y;
1192
+ if (p.x > maxX) maxX = p.x;
1193
+ if (p.y > maxY) maxY = p.y;
1194
+ }
1195
+ return { minX, minY, maxX, maxY };
1196
+ }
1197
+ function cellCentersInAABB(minX, minY, maxX, maxY) {
1198
+ const GRID_PX = CONFIG.layout.grid.stepPx;
1199
+ const x0 = Math.floor((minX - GRID_PX / 2) / GRID_PX) * GRID_PX + GRID_PX / 2;
1200
+ const y0 = Math.floor((minY - GRID_PX / 2) / GRID_PX) * GRID_PX + GRID_PX / 2;
1201
+ const out = [];
1202
+ for (let y = y0; y <= maxY; y += GRID_PX) {
1203
+ for (let x = x0; x <= maxX; x += GRID_PX) out.push({ x, y });
1204
+ }
1205
+ return out;
1206
+ }
1207
+ function cellSetForPolys(polys) {
1208
+ if (!polys.length) return /* @__PURE__ */ new Set();
1209
+ const { minX, minY, maxX, maxY } = aabb(polys);
1210
+ const centers = cellCentersInAABB(minX, minY, maxX, maxY);
1211
+ const key = (p) => `${p.x},${p.y}`;
1212
+ const S = /* @__PURE__ */ new Set();
1213
+ center: for (const c of centers) {
1214
+ for (const poly of polys) {
1215
+ if (pointInPolygon(c, poly)) {
1216
+ S.add(key(c));
1217
+ continue center;
1218
+ }
1219
+ }
1220
+ }
1221
+ return S;
1222
+ }
1223
+ function anchorsSilhouetteComplete(silPolys, piecePolys) {
1224
+ if (!silPolys.length) return false;
1225
+ const S = cellSetForPolys(silPolys);
1226
+ if (S.size === 0) return false;
1227
+ const P = cellSetForPolys(piecePolys);
1228
+ if (P.size === 0) return false;
1229
+ for (const k of S) if (!P.has(k)) return false;
1230
+ return true;
1231
+ }
1232
+ function stringSetToVecs(S) {
1233
+ const out = [];
1234
+ for (const k of S) {
1235
+ const [xs, ys] = k.split(",");
1236
+ out.push({ x: Number(xs), y: Number(ys) });
1237
+ }
1238
+ return out;
1239
+ }
1240
+ function normalizeCells(S) {
1241
+ if (S.size === 0) return S;
1242
+ const pts = stringSetToVecs(S);
1243
+ let minX = Infinity, minY = Infinity;
1244
+ for (const p of pts) {
1245
+ if (p.x < minX) minX = p.x;
1246
+ if (p.y < minY) minY = p.y;
1247
+ }
1248
+ const N = /* @__PURE__ */ new Set();
1249
+ for (const p of pts) N.add(`${p.x - minX},${p.y - minY}`);
1250
+ return N;
1251
+ }
1252
+ function setsEqual(a, b) {
1253
+ if (a.size !== b.size) return false;
1254
+ for (const k of a) if (!b.has(k)) return false;
1255
+ return true;
1256
+ }
1257
+ function anchorsWorkspaceComplete(silPolys, piecePolys) {
1258
+ if (!silPolys.length) return false;
1259
+ const Sraw = cellSetForPolys(silPolys);
1260
+ const Praw = cellSetForPolys(piecePolys);
1261
+ if (Sraw.size === 0 || Praw.size === 0) return false;
1262
+ const S = normalizeCells(Sraw);
1263
+ const P = normalizeCells(Praw);
1264
+ return setsEqual(S, P);
1265
+ }
1266
+
1267
+ function blueprintLocalFromWorld(px, py, bpGeom) {
1268
+ return { x: px - bpGeom.bx + bpGeom.cx, y: py - bpGeom.by + bpGeom.cy };
1269
+ }
1270
+ function scalePolys(polys, S) {
1271
+ if (!polys || polys.length === 0) return [];
1272
+ return polys.map((poly) => poly.map((p) => ({ x: S * p.x, y: S * p.y })));
1273
+ }
1274
+
1275
+ function usePieceState(controller) {
1276
+ const pieces = React.useMemo(() => {
1277
+ const out = [];
1278
+ for (const s of Object.values(controller.state.sectors)) {
1279
+ for (const p of s.pieces) {
1280
+ out.push({
1281
+ id: p.id,
1282
+ blueprintId: p.blueprintId,
1283
+ x: p.pos.x,
1284
+ y: p.pos.y,
1285
+ sectorId: s.sectorId
1286
+ });
1287
+ }
1288
+ }
1289
+ const floating = controller._floating;
1290
+ if (floating?.pieces) {
1291
+ for (const p of floating.pieces) {
1292
+ out.push({
1293
+ id: p.id,
1294
+ blueprintId: p.blueprintId,
1295
+ x: p.pos.x,
1296
+ y: p.pos.y
1297
+ });
1298
+ }
1299
+ }
1300
+ return out;
1301
+ }, [controller.state.sectors, controller._floating, controller.updateCount]);
1302
+ const pieceById = React.useCallback(
1303
+ (id) => id ? pieces.find((p) => p.id === id) ?? null : null,
1304
+ [pieces]
1305
+ );
1306
+ const sectorPiecePolysLive = React.useCallback(
1307
+ (sectorId) => {
1308
+ const out = [];
1309
+ const ss = controller.state.sectors[sectorId];
1310
+ if (!ss) return out;
1311
+ for (const p of ss.pieces) {
1312
+ const bp = controller.getBlueprint(p.blueprintId);
1313
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
1314
+ out.push(...piecePolysAt(bp, bb, { x: p.pos.x, y: p.pos.y }));
1315
+ }
1316
+ return out;
1317
+ },
1318
+ [controller]
1319
+ );
1320
+ const polyCacheRef = React.useRef(
1321
+ /* @__PURE__ */ new Map()
1322
+ );
1323
+ const sectorSignature = React.useCallback(
1324
+ (sectorId) => {
1325
+ const ss = controller.state.sectors[sectorId];
1326
+ if (!ss) return "";
1327
+ const parts = ss.pieces.slice().sort((a, b) => a.id < b.id ? -1 : 1).map((p) => `${p.id}@${p.pos.x},${p.pos.y}`);
1328
+ return parts.join("|");
1329
+ },
1330
+ [controller.state.sectors]
1331
+ );
1332
+ const getSectorPiecePolysCached = React.useCallback(
1333
+ (sectorId) => {
1334
+ const sig = sectorSignature(sectorId);
1335
+ const hit = polyCacheRef.current.get(sectorId);
1336
+ if (hit && hit.sig === sig) return hit.polys;
1337
+ const polys = sectorPiecePolysLive(sectorId);
1338
+ polyCacheRef.current.set(sectorId, { sig, polys });
1339
+ return polys;
1340
+ },
1341
+ [sectorSignature, sectorPiecePolysLive]
1342
+ );
1343
+ return {
1344
+ pieces,
1345
+ pieceById,
1346
+ sectorPiecePolysLive,
1347
+ getSectorPiecePolysCached
1348
+ };
1349
+ }
1350
+
1351
+ class GridSnapper {
1352
+ constructor(config) {
1353
+ this.config = config;
1354
+ }
1355
+ // ===== Reference Vertex Management =====
1356
+ /**
1357
+ * Choose a reference vertex (v0 of first poly) for snapping.
1358
+ * Consolidated from both anchors.ts and anchors-mode.ts
1359
+ */
1360
+ referenceVertex(bp) {
1361
+ const poly = "shape" in bp && bp.shape?.[0] ? bp.shape[0] : [{ x: 0, y: 0 }];
1362
+ return poly[0] ?? { x: 0, y: 0 };
1363
+ }
1364
+ // ===== Snapping Operations =====
1365
+ /**
1366
+ * Perform nearest-node snapping with configurable snap radius
1367
+ * Consolidated from both files with enhanced configuration
1368
+ */
1369
+ nearestNodeSnap(tl, bp, primitiveLookup, nodes) {
1370
+ if (!nodes.length) {
1371
+ return { tl, node: null, dist: Infinity, accepted: false };
1372
+ }
1373
+ const bb = boundsOfBlueprint(bp, primitiveLookup);
1374
+ const v0 = this.referenceVertex(bp);
1375
+ const v0World = { x: tl.x + (v0.x - bb.min.x), y: tl.y + (v0.y - bb.min.y) };
1376
+ let best = null;
1377
+ let bestD2 = Infinity;
1378
+ for (const n of nodes) {
1379
+ const dx = n.x - v0World.x;
1380
+ const dy = n.y - v0World.y;
1381
+ const d2 = dx * dx + dy * dy;
1382
+ if (d2 < bestD2) {
1383
+ bestD2 = d2;
1384
+ best = n;
1385
+ }
1386
+ }
1387
+ const distance = Math.sqrt(bestD2);
1388
+ const accepted = distance <= this.config.snapRadiusPx;
1389
+ const snapped = best && accepted ? { x: tl.x + (best.x - v0World.x), y: tl.y + (best.y - v0World.y) } : tl;
1390
+ return {
1391
+ tl: snapped,
1392
+ node: best,
1393
+ dist: distance,
1394
+ accepted
1395
+ };
1396
+ }
1397
+ /**
1398
+ * Back-compatibility shim for existing Board.tsx usage
1399
+ */
1400
+ snapTopLeftToNodes(tl, bp, primitiveLookup, nodes) {
1401
+ const { tl: snapped } = this.nearestNodeSnap(tl, bp, primitiveLookup, nodes);
1402
+ return snapped;
1403
+ }
1404
+ // ===== Node Generation =====
1405
+ /**
1406
+ * Generate workspace-band grid nodes for a sector
1407
+ * Extracted from anchors-mode.ts
1408
+ */
1409
+ generateWorkspaceNodes(layout, sector) {
1410
+ const pad = 6;
1411
+ const min = { x: layout.cx - layout.outerR, y: layout.cy - layout.outerR - pad };
1412
+ const max = { x: layout.cx + layout.outerR, y: layout.cy + layout.outerR + pad };
1413
+ const nodes = gridNodesInAABB(min, max);
1414
+ return filterNodesToBandAndSector(nodes, layout, "workspace", sector);
1415
+ }
1416
+ /**
1417
+ * Generate silhouette-mask nodes for a sector
1418
+ * Extracted from anchors-mode.ts
1419
+ */
1420
+ generateSilhouetteNodes(layout, sector, fittedMask) {
1421
+ const pad = 6;
1422
+ const min = { x: layout.cx - layout.outerR, y: layout.cy - layout.outerR - pad };
1423
+ const max = { x: layout.cx + layout.outerR, y: layout.cy + layout.outerR + pad };
1424
+ const nodes = gridNodesInAABB(min, max);
1425
+ const banded = filterNodesToBandAndSector(nodes, layout, "silhouette", sector);
1426
+ return filterNodesInPolys(banded, fittedMask, pointInPolygon);
1427
+ }
1428
+ /**
1429
+ * Generate silhouette-band nodes for a sector (no mask filter)
1430
+ * Extracted from anchors-mode.ts
1431
+ */
1432
+ generateSilhouetteBandNodes(layout, sector) {
1433
+ const pad = 6;
1434
+ const min = { x: layout.cx - layout.outerR, y: layout.cy - layout.outerR - pad };
1435
+ const max = { x: layout.cx + layout.outerR, y: layout.cy + layout.outerR + pad };
1436
+ const nodes = gridNodesInAABB(min, max);
1437
+ return filterNodesToBandAndSector(nodes, layout, "silhouette", sector);
1438
+ }
1439
+ // ===== Validation Helpers =====
1440
+ /**
1441
+ * Check if point lies inside any silhouette polygon (union semantics)
1442
+ * Private helper used by polyFullyInside
1443
+ */
1444
+ insideAnySilhouette(pt, silPolys) {
1445
+ for (const sp of silPolys) {
1446
+ if (pointInPolygon(pt, sp)) return true;
1447
+ }
1448
+ return false;
1449
+ }
1450
+ /**
1451
+ * Robust "fully inside" check with configurable sampling density
1452
+ * Consolidated from both files with enhanced configuration
1453
+ */
1454
+ polyFullyInside(piecePolys, silhouettePolys, customStep) {
1455
+ if (!piecePolys.length) return true;
1456
+ if (!silhouettePolys.length) return false;
1457
+ const step = customStep ?? this.config.densitySampleStepPx ?? Math.max(1, Math.round(CONFIG.layout.grid.stepPx / 3));
1458
+ for (const poly of piecePolys) {
1459
+ if (poly.length < 2) continue;
1460
+ for (let i = 0; i < poly.length; i++) {
1461
+ const a = poly[i];
1462
+ const b = poly[(i + 1) % poly.length];
1463
+ if (!a || !b) continue;
1464
+ const dx = b.x - a.x;
1465
+ const dy = b.y - a.y;
1466
+ const len = Math.hypot(dx, dy);
1467
+ const n = Math.max(1, Math.ceil(len / step));
1468
+ for (let k = 0; k <= n; k++) {
1469
+ const t = k / n;
1470
+ const p = { x: a.x + t * dx, y: a.y + t * dy };
1471
+ if (!this.insideAnySilhouette(p, silhouettePolys)) {
1472
+ return false;
1473
+ }
1474
+ }
1475
+ }
1476
+ }
1477
+ return true;
1478
+ }
1479
+ }
1480
+ function createDefaultGridSnapper() {
1481
+ return new GridSnapper({
1482
+ stepPx: CONFIG.layout.grid.stepPx,
1483
+ snapRadiusPx: CONFIG.game.snapRadiusPx,
1484
+ densitySampleStepPx: Math.max(1, Math.round(CONFIG.layout.grid.stepPx / 3))
1485
+ });
1486
+ }
1487
+ const defaultGridSnapper = createDefaultGridSnapper();
1488
+ function nearestNodeSnap(tl, bp, primitiveLookup, nodes) {
1489
+ const result = defaultGridSnapper.nearestNodeSnap(tl, bp, primitiveLookup, nodes);
1490
+ return { tl: result.tl, node: result.node, dist: result.dist };
1491
+ }
1492
+ function polyFullyInside(piecePolys, silhouettePolys, step) {
1493
+ return defaultGridSnapper.polyFullyInside(piecePolys, silhouettePolys, step);
1494
+ }
1495
+ function workspaceNodes(layout, sector) {
1496
+ return defaultGridSnapper.generateWorkspaceNodes(layout, sector);
1497
+ }
1498
+ function silhouetteNodes(layout, sector, fittedMask) {
1499
+ return defaultGridSnapper.generateSilhouetteNodes(layout, sector, fittedMask);
1500
+ }
1501
+ function silhouetteBandNodes(layout, sector) {
1502
+ return defaultGridSnapper.generateSilhouetteBandNodes(layout, sector);
1503
+ }
1504
+
1505
+ function useAnchorGrid(controller, layout, scaleS) {
1506
+ const cfg = controller.state.cfg;
1507
+ const anchorDots = React.useMemo(() => {
1508
+ const out = [];
1509
+ out.push({ sectorId: "inner-ring", valid: innerRingNodes(layout), invalid: [] });
1510
+ for (const s of layout.sectors) {
1511
+ if (cfg.target === "workspace") {
1512
+ out.push({ sectorId: s.id, valid: workspaceNodes(layout, s), invalid: [] });
1513
+ } else {
1514
+ const mask = controller.state.cfg.sectors.find((ss) => ss.id === s.id)?.silhouette.mask ?? [];
1515
+ if (!mask || mask.length === 0) continue;
1516
+ const rect = rectForBand(layout, s, "silhouette", 1);
1517
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(mask, scaleS, { cx: rect.cx, cy: rect.cy });
1518
+ const bandAll = silhouetteBandNodes(layout, s);
1519
+ const valid = silhouetteNodes(layout, s, placedPolys);
1520
+ const key = (p) => `${p.x},${p.y}`;
1521
+ const validSet = new Set(valid.map(key));
1522
+ const invalid = bandAll.filter((p) => !validSet.has(key(p)));
1523
+ out.push({ sectorId: s.id, valid, invalid });
1524
+ }
1525
+ }
1526
+ return out;
1527
+ }, [cfg.target, controller.state.sectors, layout, scaleS]);
1528
+ return {
1529
+ anchorDots
1530
+ };
1531
+ }
1532
+
1533
+ function edgeToUnitSegments(start, end, gridSize) {
1534
+ const segments = [];
1535
+ const startGrid = {
1536
+ x: Math.round(start.x / gridSize),
1537
+ y: Math.round(start.y / gridSize)
1538
+ };
1539
+ const endGrid = {
1540
+ x: Math.round(end.x / gridSize),
1541
+ y: Math.round(end.y / gridSize)
1542
+ };
1543
+ const dx = endGrid.x - startGrid.x;
1544
+ const dy = endGrid.y - startGrid.y;
1545
+ if (dx === 0 && dy === 0) return [];
1546
+ const steps = Math.max(Math.abs(dx), Math.abs(dy));
1547
+ const stepX = dx / steps;
1548
+ const stepY = dy / steps;
1549
+ for (let i = 0; i < steps; i++) {
1550
+ const aX = Math.round(startGrid.x + i * stepX);
1551
+ const aY = Math.round(startGrid.y + i * stepY);
1552
+ const bX = Math.round(startGrid.x + (i + 1) * stepX);
1553
+ const bY = Math.round(startGrid.y + (i + 1) * stepY);
1554
+ segments.push({
1555
+ a: { x: aX, y: aY },
1556
+ b: { x: bX, y: bY }
1557
+ });
1558
+ }
1559
+ return segments;
1560
+ }
1561
+ function getPieceUnitSegments(piece, getBlueprint, getPrimitive, gridSize) {
1562
+ const blueprint = getBlueprint(piece.blueprintId);
1563
+ if (!blueprint?.shape) return [];
1564
+ const bb = boundsOfBlueprint(blueprint, getPrimitive);
1565
+ const ox = piece.pos.x - bb.min.x;
1566
+ const oy = piece.pos.y - bb.min.y;
1567
+ const allSegments = [];
1568
+ for (const poly of blueprint.shape) {
1569
+ const translatedPoly = poly.map((vertex) => ({
1570
+ x: vertex.x + ox,
1571
+ y: vertex.y + oy
1572
+ }));
1573
+ for (let i = 0; i < translatedPoly.length; i++) {
1574
+ const current = translatedPoly[i];
1575
+ const next = translatedPoly[(i + 1) % translatedPoly.length];
1576
+ if (!current || !next) continue;
1577
+ allSegments.push(...edgeToUnitSegments(current, next, gridSize));
1578
+ }
1579
+ }
1580
+ return allSegments;
1581
+ }
1582
+ function checkFaceToFaceAttachment(piece, existingPieces, getBlueprint, getPrimitive) {
1583
+ if (existingPieces.length === 0) {
1584
+ return { isAttached: true, attachedPieceIds: [], sharedEdgeLength: 0 };
1585
+ }
1586
+ const gridSize = CONFIG.layout.grid.stepPx;
1587
+ const newPieceSegments = getPieceUnitSegments(piece, getBlueprint, getPrimitive, gridSize);
1588
+ if (newPieceSegments.length === 0) {
1589
+ return { isAttached: false, attachedPieceIds: [], sharedEdgeLength: 0 };
1590
+ }
1591
+ let totalSharedLength = 0;
1592
+ const attachedPieceIds = [];
1593
+ for (const existingPiece of existingPieces) {
1594
+ const existingSegments = getPieceUnitSegments(existingPiece, getBlueprint, getPrimitive, gridSize);
1595
+ const sharedSegments = newPieceSegments.filter(
1596
+ (newSeg) => existingSegments.some(
1597
+ (existingSeg) => (
1598
+ // Check if segments share the same endpoints (identical or reversed)
1599
+ newSeg.a.x === existingSeg.a.x && newSeg.a.y === existingSeg.a.y && newSeg.b.x === existingSeg.b.x && newSeg.b.y === existingSeg.b.y || newSeg.a.x === existingSeg.b.x && newSeg.a.y === existingSeg.b.y && newSeg.b.x === existingSeg.a.x && newSeg.b.y === existingSeg.a.y
1600
+ )
1601
+ )
1602
+ );
1603
+ if (sharedSegments.length > 0) {
1604
+ totalSharedLength += sharedSegments.length;
1605
+ attachedPieceIds.push(existingPiece.id);
1606
+ }
1607
+ }
1608
+ return {
1609
+ isAttached: totalSharedLength >= 1,
1610
+ attachedPieceIds,
1611
+ sharedEdgeLength: totalSharedLength
1612
+ };
1613
+ }
1614
+ function wouldRemovalDisconnectSector(pieceToRemove, allPiecesInSector, getBlueprint, getPrimitive) {
1615
+ if (allPiecesInSector.length <= 2) return false;
1616
+ const remainingPieces = allPiecesInSector.filter((p) => p.id !== pieceToRemove.id);
1617
+ if (remainingPieces.length <= 1) return false;
1618
+ const adjacencyMap = /* @__PURE__ */ new Map();
1619
+ for (const piece of remainingPieces) {
1620
+ adjacencyMap.set(piece.id, []);
1621
+ }
1622
+ for (let i = 0; i < remainingPieces.length; i++) {
1623
+ for (let j = i + 1; j < remainingPieces.length; j++) {
1624
+ const piece1 = remainingPieces[i];
1625
+ const piece2 = remainingPieces[j];
1626
+ if (!piece1 || !piece2) continue;
1627
+ const result = checkFaceToFaceAttachment(piece1, [piece2], getBlueprint, getPrimitive);
1628
+ if (result.isAttached) {
1629
+ adjacencyMap.get(piece1.id).push(piece2.id);
1630
+ adjacencyMap.get(piece2.id).push(piece1.id);
1631
+ }
1632
+ }
1633
+ }
1634
+ const visited = /* @__PURE__ */ new Set();
1635
+ const startPiece = remainingPieces[0];
1636
+ if (!startPiece) return false;
1637
+ function dfs(pieceId) {
1638
+ if (visited.has(pieceId)) return;
1639
+ visited.add(pieceId);
1640
+ const neighbors = adjacencyMap.get(pieceId) || [];
1641
+ for (const neighborId of neighbors) {
1642
+ dfs(neighborId);
1643
+ }
1644
+ }
1645
+ dfs(startPiece.id);
1646
+ return visited.size !== remainingPieces.length;
1647
+ }
1648
+
1649
+ function useDragController(controller, layout, pieces, pieceById, anchorDots, placedSilBySector, isSectorLocked, maybeCompleteSector, force, clickController, tracker) {
1650
+ const cfg = controller.state.cfg;
1651
+ const clickMode = cfg.input === "click";
1652
+ const svgRef = React.useRef(null);
1653
+ const dragRef = React.useRef(null);
1654
+ const [draggingId, setDraggingId] = React.useState(null);
1655
+ const elMap = React.useRef(/* @__PURE__ */ new Map());
1656
+ const [lockedPieceId, setLockedPieceId] = React.useState(null);
1657
+ React.useEffect(() => {
1658
+ if (draggingId === null) {
1659
+ dragRef.current = null;
1660
+ }
1661
+ }, [draggingId]);
1662
+ const setPieceRef = (id) => (el) => {
1663
+ const m = elMap.current;
1664
+ if (el) m.set(id, el);
1665
+ else m.delete(id);
1666
+ };
1667
+ const svgPoint = (clientX, clientY) => {
1668
+ const svg = svgRef.current;
1669
+ const pt = svg.createSVGPoint();
1670
+ pt.x = clientX;
1671
+ pt.y = clientY;
1672
+ const ctm = svg.getScreenCTM();
1673
+ if (!ctm) return { x: 0, y: 0 };
1674
+ const sp = pt.matrixTransform(ctm.inverse());
1675
+ return { x: sp.x, y: sp.y };
1676
+ };
1677
+ const scheduleFrame = () => {
1678
+ if (!dragRef.current) return;
1679
+ const d = dragRef.current;
1680
+ if (d.raf != null) return;
1681
+ d.raf = requestAnimationFrame(() => {
1682
+ d.raf = null;
1683
+ const g = elMap.current.get(d.id);
1684
+ if (g) g.setAttribute("transform", `translate(${d.tlx - d.minx}, ${d.tly - d.miny})`);
1685
+ });
1686
+ };
1687
+ const sectorIdAt = (x, y) => sectorAtPoint(x, y, layout, cfg.target);
1688
+ const GRID_STEP = CONFIG.layout.grid.stepPx;
1689
+ const anchorIdFromNode = (node) => {
1690
+ if (!node) return null;
1691
+ const ax = Math.round(node.x / GRID_STEP);
1692
+ const ay = Math.round(node.y / GRID_STEP);
1693
+ return `${ax},${ay}`;
1694
+ };
1695
+ const anchorNodeFromPoint = (point) => ({
1696
+ x: Math.round(point.x / GRID_STEP) * GRID_STEP,
1697
+ y: Math.round(point.y / GRID_STEP) * GRID_STEP
1698
+ });
1699
+ const updatePointerAnchor = (point, sectorId) => {
1700
+ if (!dragRef.current) return;
1701
+ const d = dragRef.current;
1702
+ const anchorNode = anchorNodeFromPoint(point);
1703
+ const anchorId = anchorIdFromNode(anchorNode);
1704
+ if (tracker?.recordMouseMove && (anchorId !== d.pointerAnchorId || sectorId !== d.pointerAnchorSectorId)) {
1705
+ tracker.recordMouseMove(anchorNode.x, anchorNode.y, sectorId);
1706
+ }
1707
+ d.pointerAnchor = anchorNode;
1708
+ d.pointerAnchorId = anchorId;
1709
+ d.pointerAnchorSectorId = sectorId;
1710
+ };
1711
+ const updateSnapAnchor = (node, sectorId, dist) => {
1712
+ if (!dragRef.current) return;
1713
+ const d = dragRef.current;
1714
+ d.snapAnchor = node;
1715
+ d.snapAnchorId = anchorIdFromNode(node);
1716
+ d.snapAnchorSectorId = sectorId;
1717
+ d.snapAnchorDist = node && dist !== void 0 ? dist : null;
1718
+ };
1719
+ const emitClickEvent = (location, clickType, metadata) => {
1720
+ if (!tracker?.recordClickEvent) return;
1721
+ tracker.recordClickEvent(location, clickType, metadata);
1722
+ };
1723
+ const getPieceVertices = (pieceId) => {
1724
+ const piece = controller.findPiece(pieceId);
1725
+ if (!piece) return [];
1726
+ const bp = controller.getBlueprint(piece.blueprintId);
1727
+ if (!bp) return [];
1728
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
1729
+ const polys = piecePolysAt(bp, bb, piece.pos);
1730
+ return polys.map((ring) => ring.map((pt) => [pt.x, pt.y]));
1731
+ };
1732
+ const onPiecePointerDown = (e, p) => {
1733
+ if (p.sectorId && isSectorLocked(p.sectorId)) return;
1734
+ if (clickMode && draggingId) return;
1735
+ if (cfg.mode === "prep" && p.sectorId) {
1736
+ const allPiecesInSector = controller.getPiecesInSector(p.sectorId);
1737
+ const pieceToRemove = controller.findPiece(p.id);
1738
+ if (pieceToRemove && wouldRemovalDisconnectSector(
1739
+ pieceToRemove,
1740
+ allPiecesInSector,
1741
+ (id) => controller.getBlueprint(id),
1742
+ (kind) => controller.getPrimitive(kind)
1743
+ )) {
1744
+ setLockedPieceId(p.id);
1745
+ setTimeout(() => setLockedPieceId(null), 500);
1746
+ return;
1747
+ }
1748
+ }
1749
+ if (clickMode) {
1750
+ e.stopPropagation();
1751
+ const bp2 = controller.getBlueprint(p.blueprintId);
1752
+ const bb2 = boundsOfBlueprint(bp2, (k) => controller.getPrimitive(k));
1753
+ const support2 = computeSupportOffsets(bp2, bb2);
1754
+ const { x: x2, y: y2 } = svgPoint(e.clientX, e.clientY);
1755
+ const pointerAnchorNode2 = anchorNodeFromPoint({ x: x2, y: y2 });
1756
+ const pointerSector2 = p.sectorId;
1757
+ if (tracker?.recordMouseMove) {
1758
+ tracker.recordMouseMove(pointerAnchorNode2.x, pointerAnchorNode2.y, pointerSector2);
1759
+ }
1760
+ if (tracker) {
1761
+ const blueprintType = "kind" in bp2 ? "primitive" : "composite";
1762
+ tracker.recordPickup(
1763
+ p.id,
1764
+ p.blueprintId,
1765
+ blueprintType,
1766
+ "sector",
1767
+ { x: p.x, y: p.y },
1768
+ getPieceVertices(p.id),
1769
+ p.sectorId
1770
+ );
1771
+ }
1772
+ dragRef.current = {
1773
+ id: p.id,
1774
+ dx: x2 - p.x,
1775
+ dy: y2 - p.y,
1776
+ tlx: p.x,
1777
+ tly: p.y,
1778
+ aabb: { width: bb2.width, height: bb2.height },
1779
+ minx: bb2.min.x,
1780
+ miny: bb2.min.y,
1781
+ support: support2,
1782
+ fromIcon: false,
1783
+ validSnap: true,
1784
+ prevValidSnap: true,
1785
+ raf: null,
1786
+ originalPos: { x: p.x, y: p.y, sectorId: p.sectorId },
1787
+ pointerAnchor: pointerAnchorNode2,
1788
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode2),
1789
+ pointerAnchorSectorId: pointerSector2,
1790
+ snapAnchor: null,
1791
+ snapAnchorId: null,
1792
+ snapAnchorSectorId: void 0,
1793
+ snapAnchorDist: null,
1794
+ overlaps: false,
1795
+ maxExceeded: false
1796
+ };
1797
+ updateSnapAnchor(null, p.sectorId);
1798
+ setDraggingId(p.id);
1799
+ force();
1800
+ return;
1801
+ }
1802
+ svgRef.current?.setPointerCapture(e.pointerId);
1803
+ const bp = controller.getBlueprint(p.blueprintId);
1804
+ const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
1805
+ const support = computeSupportOffsets(bp, bb);
1806
+ const { x, y } = svgPoint(e.clientX, e.clientY);
1807
+ const pointerAnchorNode = anchorNodeFromPoint({ x, y });
1808
+ const pointerSector = p.sectorId;
1809
+ if (tracker?.recordMouseMove) {
1810
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
1811
+ }
1812
+ if (tracker) {
1813
+ const blueprintType = "kind" in bp ? "primitive" : "composite";
1814
+ tracker.recordPickup(
1815
+ p.id,
1816
+ p.blueprintId,
1817
+ blueprintType,
1818
+ "sector",
1819
+ { x: p.x, y: p.y },
1820
+ getPieceVertices(p.id),
1821
+ p.sectorId
1822
+ );
1823
+ }
1824
+ dragRef.current = {
1825
+ id: p.id,
1826
+ dx: x - p.x,
1827
+ dy: y - p.y,
1828
+ tlx: p.x,
1829
+ tly: p.y,
1830
+ aabb: { width: bb.width, height: bb.height },
1831
+ minx: bb.min.x,
1832
+ miny: bb.min.y,
1833
+ support,
1834
+ fromIcon: false,
1835
+ validSnap: true,
1836
+ prevValidSnap: true,
1837
+ raf: null,
1838
+ originalPos: { x: p.x, y: p.y, sectorId: p.sectorId },
1839
+ pointerAnchor: pointerAnchorNode,
1840
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
1841
+ pointerAnchorSectorId: pointerSector,
1842
+ snapAnchor: null,
1843
+ snapAnchorId: null,
1844
+ snapAnchorSectorId: void 0,
1845
+ snapAnchorDist: null,
1846
+ overlaps: false,
1847
+ maxExceeded: false
1848
+ };
1849
+ updateSnapAnchor(null, p.sectorId);
1850
+ setDraggingId(p.id);
1851
+ force();
1852
+ };
1853
+ const onPointerMove = (e) => {
1854
+ const { x, y } = svgPoint(e.clientX, e.clientY);
1855
+ const pointerAnchorNode = anchorNodeFromPoint({ x, y });
1856
+ const pointerR = Math.hypot(x - layout.cx, y - layout.cy);
1857
+ const pointerSectorId = pointerR < layout.innerR ? void 0 : sectorIdAt(x, y) ?? void 0;
1858
+ if (!dragRef.current) {
1859
+ if (tracker?.recordMouseMove) {
1860
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSectorId);
1861
+ }
1862
+ return;
1863
+ }
1864
+ const d = dragRef.current;
1865
+ const tlx = x - d.dx, tly = y - d.dy;
1866
+ updatePointerAnchor({ x, y }, pointerSectorId);
1867
+ const clamped = clampTopLeftBySupport(
1868
+ tlx,
1869
+ tly,
1870
+ { aabb: d.aabb, support: d.support },
1871
+ layout,
1872
+ controller.state.cfg.target,
1873
+ pointerR < layout.innerR
1874
+ );
1875
+ let tl = clamped;
1876
+ const centerX = tl.x + d.aabb.width / 2;
1877
+ const centerY = tl.y + d.aabb.height / 2;
1878
+ const rFromCenter = Math.hypot(centerX - layout.cx, centerY - layout.cy);
1879
+ const inInnerRing = rFromCenter < layout.innerR;
1880
+ const secId = inInnerRing ? null : sectorIdAt(centerX, centerY);
1881
+ let snapNode = null;
1882
+ let snapDist = null;
1883
+ let snapSectorId = secId ?? void 0;
1884
+ let overlapsDetected = false;
1885
+ let maxExceededDetected = false;
1886
+ if (inInnerRing) {
1887
+ const innerEntry = anchorDots.find((a) => a.sectorId === "inner-ring");
1888
+ if (innerEntry && innerEntry.valid.length) {
1889
+ const piece = pieceById(d.id);
1890
+ if (piece) {
1891
+ const bp = controller.getBlueprint(piece.blueprintId);
1892
+ const snap = nearestNodeSnap(tl, bp, controller.getPrimitive, innerEntry.valid);
1893
+ tl = snap.tl;
1894
+ snapNode = snap.node;
1895
+ snapDist = snap.dist;
1896
+ snapSectorId = void 0;
1897
+ }
1898
+ }
1899
+ d.validSnap = false;
1900
+ } else if (secId) {
1901
+ const isCompletedSector = isSectorLocked(secId);
1902
+ const entry = anchorDots.find((a) => a.sectorId === secId);
1903
+ const allNodes = cfg.target === "workspace" && cfg.mode !== "prep" ? entry?.valid ?? [] : [...entry?.valid ?? [], ...entry?.invalid ?? []];
1904
+ if (allNodes.length) {
1905
+ const piece = pieceById(d.id);
1906
+ if (piece) {
1907
+ const bp = controller.getBlueprint(piece.blueprintId);
1908
+ const snap = nearestNodeSnap(
1909
+ tl,
1910
+ bp,
1911
+ controller.getPrimitive,
1912
+ allNodes
1913
+ );
1914
+ tl = snap.tl;
1915
+ snapNode = snap.node;
1916
+ snapDist = snap.dist;
1917
+ snapSectorId = secId;
1918
+ if (isCompletedSector) {
1919
+ d.validSnap = false;
1920
+ } else {
1921
+ const radius = controller.state.cfg.snapRadiusPx;
1922
+ const carried = pieceById(d.id);
1923
+ let overlapsOk = true;
1924
+ if (carried) {
1925
+ const bpCar = controller.getBlueprint(carried.blueprintId);
1926
+ const bbCar = boundsOfBlueprint(
1927
+ bpCar,
1928
+ controller.getPrimitive
1929
+ );
1930
+ const carriedPolys = piecePolysAt(bpCar, bbCar, tl);
1931
+ const sameSector = pieces.filter(
1932
+ (pp) => pp.id !== d.id && sectorIdAt(
1933
+ pp.x + boundsOfBlueprint(
1934
+ controller.getBlueprint(pp.blueprintId),
1935
+ controller.getPrimitive
1936
+ ).width / 2,
1937
+ pp.y + boundsOfBlueprint(
1938
+ controller.getBlueprint(pp.blueprintId),
1939
+ controller.getPrimitive
1940
+ ).height / 2
1941
+ ) === secId
1942
+ );
1943
+ for (const other of sameSector) {
1944
+ const bpO = controller.getBlueprint(other.blueprintId);
1945
+ const bbO = boundsOfBlueprint(
1946
+ bpO,
1947
+ controller.getPrimitive
1948
+ );
1949
+ const otherPolys = piecePolysAt(bpO, bbO, { x: other.x, y: other.y });
1950
+ if (polysOverlap(carriedPolys, otherPolys)) {
1951
+ overlapsOk = false;
1952
+ break;
1953
+ }
1954
+ }
1955
+ if (cfg.target === "workspace") {
1956
+ if (cfg.mode === "prep") {
1957
+ const allPiecesInSector = controller.getPiecesInSector(secId);
1958
+ const otherPiecesInSector = allPiecesInSector.filter((piece2) => piece2.id !== d.id);
1959
+ const isFirstPiece = otherPiecesInSector.length === 0;
1960
+ const storedPiece = controller.findPiece(d.id);
1961
+ const currentSectorId = storedPiece?.sectorId;
1962
+ const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
1963
+ const enforceMaxPieces = maxPieces > 0;
1964
+ const effectiveCount = allPiecesInSector.length - (currentSectorId === secId ? 1 : 0);
1965
+ const wouldExceedMax = enforceMaxPieces && effectiveCount + 1 > maxPieces;
1966
+ if (isFirstPiece) {
1967
+ d.validSnap = overlapsOk && !wouldExceedMax;
1968
+ maxExceededDetected = wouldExceedMax;
1969
+ } else if (storedPiece) {
1970
+ const pieceAtDragPosition = {
1971
+ ...storedPiece,
1972
+ pos: { x: tl.x, y: tl.y }
1973
+ };
1974
+ const attachmentResult = checkFaceToFaceAttachment(
1975
+ pieceAtDragPosition,
1976
+ otherPiecesInSector,
1977
+ (id) => controller.getBlueprint(id),
1978
+ (kind) => controller.getPrimitive(kind)
1979
+ );
1980
+ d.validSnap = overlapsOk && attachmentResult.isAttached && !wouldExceedMax;
1981
+ maxExceededDetected = wouldExceedMax;
1982
+ } else {
1983
+ d.validSnap = overlapsOk && !wouldExceedMax;
1984
+ maxExceededDetected = wouldExceedMax;
1985
+ }
1986
+ } else {
1987
+ d.validSnap = overlapsOk;
1988
+ maxExceededDetected = false;
1989
+ }
1990
+ overlapsDetected = !overlapsOk;
1991
+ } else {
1992
+ const key = (p) => `${p.x},${p.y}`;
1993
+ const validSet = new Set((entry?.valid ?? []).map(key));
1994
+ const nodeOk = !!snap.node && validSet.has(key(snap.node)) && snap.dist <= (radius ?? 0);
1995
+ let insideOk = false;
1996
+ if (nodeOk) {
1997
+ const silPolys = placedSilBySector.get(secId) ?? [];
1998
+ insideOk = polyFullyInside(carriedPolys, silPolys);
1999
+ }
2000
+ d.validSnap = nodeOk && insideOk && overlapsOk;
2001
+ overlapsDetected = !overlapsOk;
2002
+ }
2003
+ }
2004
+ }
2005
+ }
2006
+ }
2007
+ } else {
2008
+ d.validSnap = controller.state.cfg.target === "workspace";
2009
+ snapNode = null;
2010
+ snapDist = null;
2011
+ snapSectorId = void 0;
2012
+ maxExceededDetected = false;
2013
+ }
2014
+ d.overlaps = overlapsDetected;
2015
+ d.maxExceeded = maxExceededDetected;
2016
+ updateSnapAnchor(snapNode, snapSectorId, snapDist ?? void 0);
2017
+ d.tlx = tl.x;
2018
+ d.tly = tl.y;
2019
+ if (d.prevValidSnap !== d.validSnap) {
2020
+ d.prevValidSnap = d.validSnap ?? false;
2021
+ force();
2022
+ }
2023
+ scheduleFrame();
2024
+ };
2025
+ const onPointerUp = () => {
2026
+ if (!dragRef.current) return;
2027
+ if (clickMode) return;
2028
+ const d = dragRef.current;
2029
+ if (d.validSnap === false) {
2030
+ const piece = controller.findPiece(d.id);
2031
+ const cfg2 = controller.state.cfg;
2032
+ const centerX2 = d.tlx + d.aabb.width / 2;
2033
+ const centerY2 = d.tly + d.aabb.height / 2;
2034
+ const attemptedSector = sectorIdAt(centerX2, centerY2);
2035
+ let invalidReason = "outside_bounds";
2036
+ if (d.maxExceeded) {
2037
+ invalidReason = "no_valid_anchor";
2038
+ } else if (d.overlaps) {
2039
+ invalidReason = "overlapping";
2040
+ } else if (attemptedSector) {
2041
+ invalidReason = "no_valid_anchor";
2042
+ }
2043
+ emitClickEvent(
2044
+ { x: centerX2, y: centerY2 },
2045
+ "invalid_placement",
2046
+ { invalidPlacement: { reason: invalidReason, attemptedSectorId: attemptedSector ?? void 0 } }
2047
+ );
2048
+ if (cfg2.mode === "prep" && d.originalPos) {
2049
+ controller.move(d.id, { x: d.originalPos.x, y: d.originalPos.y });
2050
+ if (tracker) {
2051
+ tracker.recordPlacedown(
2052
+ "placed",
2053
+ d.originalPos.sectorId,
2054
+ { x: d.originalPos.x, y: d.originalPos.y },
2055
+ getPieceVertices(d.id),
2056
+ d.snapAnchorId ?? void 0,
2057
+ false,
2058
+ d.overlaps ?? false
2059
+ );
2060
+ }
2061
+ } else {
2062
+ const removedFromSector = piece?.sectorId;
2063
+ controller.remove(d.id);
2064
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
2065
+ if (tracker) {
2066
+ tracker.recordPlacedown(
2067
+ "deleted",
2068
+ removedFromSector,
2069
+ void 0,
2070
+ void 0,
2071
+ d.snapAnchorId ?? void 0,
2072
+ false,
2073
+ d.overlaps ?? false
2074
+ );
2075
+ }
2076
+ }
2077
+ dragRef.current = null;
2078
+ setDraggingId(null);
2079
+ force();
2080
+ return;
2081
+ }
2082
+ dragRef.current = null;
2083
+ if (d.raf) {
2084
+ cancelAnimationFrame(d.raf);
2085
+ d.raf = null;
2086
+ }
2087
+ const commitTL = { x: d.tlx, y: d.tly };
2088
+ const centerX = commitTL.x + d.aabb.width / 2;
2089
+ const centerY = commitTL.y + d.aabb.height / 2;
2090
+ const rFromCenter = Math.hypot(centerX - layout.cx, centerY - layout.cy);
2091
+ if (rFromCenter < layout.innerR) {
2092
+ const piece = controller.findPiece(d.id);
2093
+ const removedFromSector = piece?.sectorId;
2094
+ controller.remove(d.id);
2095
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
2096
+ if (tracker) {
2097
+ tracker.recordPlacedown(
2098
+ "deleted",
2099
+ removedFromSector,
2100
+ void 0,
2101
+ void 0,
2102
+ d.snapAnchorId ?? void 0,
2103
+ false,
2104
+ d.overlaps ?? false
2105
+ );
2106
+ }
2107
+ setDraggingId(null);
2108
+ force();
2109
+ return;
2110
+ }
2111
+ const secId = sectorIdAt(centerX, centerY);
2112
+ if (secId && isSectorLocked(secId)) {
2113
+ emitClickEvent(
2114
+ { x: centerX, y: centerY },
2115
+ "sector_complete_attempt",
2116
+ { invalidPlacement: { reason: "sector_complete", attemptedSectorId: secId } }
2117
+ );
2118
+ if (d.originalPos) {
2119
+ controller.move(d.id, { x: d.originalPos.x, y: d.originalPos.y });
2120
+ if (d.originalPos.sectorId) {
2121
+ controller.drop(d.id, d.originalPos.sectorId);
2122
+ }
2123
+ if (tracker) {
2124
+ tracker.recordPlacedown(
2125
+ "placed",
2126
+ d.originalPos.sectorId,
2127
+ { x: d.originalPos.x, y: d.originalPos.y },
2128
+ getPieceVertices(d.id),
2129
+ d.snapAnchorId ?? void 0,
2130
+ false,
2131
+ d.overlaps ?? false
2132
+ );
2133
+ }
2134
+ } else {
2135
+ controller.remove(d.id);
2136
+ if (tracker) {
2137
+ tracker.recordPlacedown(
2138
+ "deleted",
2139
+ void 0,
2140
+ void 0,
2141
+ void 0,
2142
+ d.snapAnchorId ?? void 0,
2143
+ false,
2144
+ d.overlaps ?? false
2145
+ );
2146
+ }
2147
+ }
2148
+ setDraggingId(null);
2149
+ force();
2150
+ return;
2151
+ }
2152
+ controller.move(d.id, commitTL);
2153
+ controller.drop(d.id, secId);
2154
+ const justCompleted = secId ? maybeCompleteSector(secId) : false;
2155
+ const wasValid = d.validSnap === void 0 ? true : d.validSnap;
2156
+ if (tracker) {
2157
+ tracker.recordPlacedown(
2158
+ "placed",
2159
+ secId,
2160
+ commitTL,
2161
+ getPieceVertices(d.id),
2162
+ d.snapAnchorId ?? void 0,
2163
+ wasValid,
2164
+ d.overlaps ?? false,
2165
+ justCompleted
2166
+ );
2167
+ }
2168
+ setDraggingId(null);
2169
+ if (d.fromIcon && controller.state.blueprintView === "primitives") {
2170
+ controller.switchBlueprintView();
2171
+ }
2172
+ force();
2173
+ };
2174
+ const onBlueprintPointerDown = (e, bp, bpGeom) => {
2175
+ if (clickMode && draggingId) return;
2176
+ e.stopPropagation();
2177
+ const { x: px, y: py } = svgPoint(e.clientX, e.clientY);
2178
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
2179
+ const support = computeSupportOffsets(bp, bb);
2180
+ if (clickMode) {
2181
+ const q2 = blueprintLocalFromWorld(px, py, bpGeom);
2182
+ const tl02 = { x: px - (q2.x - bb.min.x), y: py - (q2.y - bb.min.y) };
2183
+ const pointerR2 = Math.hypot(px - layout.cx, py - layout.cy);
2184
+ const allowInside2 = pointerR2 < layout.innerR;
2185
+ const clamped2 = clampTopLeftBySupport(
2186
+ tl02.x,
2187
+ tl02.y,
2188
+ { aabb: bb, support },
2189
+ layout,
2190
+ controller.state.cfg.target,
2191
+ allowInside2
2192
+ );
2193
+ const pieceId2 = controller.spawnFromBlueprint(bp, clamped2);
2194
+ const pointerAnchorNode2 = anchorNodeFromPoint({ x: px, y: py });
2195
+ const pointerSector2 = sectorIdAt(px, py) ?? void 0;
2196
+ if (tracker?.recordMouseMove) {
2197
+ tracker.recordMouseMove(pointerAnchorNode2.x, pointerAnchorNode2.y, pointerSector2);
2198
+ }
2199
+ if (tracker) {
2200
+ const blueprintType = "kind" in bp ? "primitive" : "composite";
2201
+ tracker.recordPickup(
2202
+ pieceId2,
2203
+ bp.id,
2204
+ blueprintType,
2205
+ "blueprint",
2206
+ clamped2,
2207
+ getPieceVertices(pieceId2),
2208
+ void 0
2209
+ );
2210
+ }
2211
+ const tl = clamped2;
2212
+ let finalTl = tl;
2213
+ let snapInfo = null;
2214
+ const centerX = tl.x + bb.width / 2;
2215
+ const centerY = tl.y + bb.height / 2;
2216
+ const secId = sectorIdAt(centerX, centerY);
2217
+ if (secId) {
2218
+ const entry = anchorDots.find((a) => a.sectorId === secId);
2219
+ const nodes = controller.state.cfg.target === "workspace" ? entry?.valid ?? [] : [...entry?.valid ?? [], ...entry?.invalid ?? []];
2220
+ if (nodes.length) {
2221
+ const snap = nearestNodeSnap(tl, bp, controller.getPrimitive, nodes);
2222
+ finalTl = snap.tl;
2223
+ snapInfo = { node: snap.node, dist: snap.dist };
2224
+ }
2225
+ }
2226
+ controller.move(pieceId2, finalTl);
2227
+ dragRef.current = {
2228
+ id: pieceId2,
2229
+ dx: px - finalTl.x,
2230
+ dy: py - finalTl.y,
2231
+ tlx: finalTl.x,
2232
+ tly: finalTl.y,
2233
+ aabb: bb,
2234
+ minx: bb.min.x,
2235
+ miny: bb.min.y,
2236
+ support,
2237
+ fromIcon: true,
2238
+ validSnap: true,
2239
+ prevValidSnap: true,
2240
+ raf: null,
2241
+ originalPos: void 0,
2242
+ // No original position for fromIcon drags
2243
+ pointerAnchor: pointerAnchorNode2,
2244
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode2),
2245
+ pointerAnchorSectorId: pointerSector2,
2246
+ snapAnchor: null,
2247
+ snapAnchorId: null,
2248
+ snapAnchorSectorId: void 0,
2249
+ snapAnchorDist: null,
2250
+ overlaps: false,
2251
+ maxExceeded: false
2252
+ };
2253
+ updatePointerAnchor({ x: px, y: py }, pointerSector2);
2254
+ const snappingSecId = sectorIdAt(finalTl.x + bb.width / 2, finalTl.y + bb.height / 2) ?? void 0;
2255
+ updateSnapAnchor(snapInfo?.node ?? null, snappingSecId, snapInfo?.dist);
2256
+ setDraggingId(pieceId2);
2257
+ force();
2258
+ scheduleFrame();
2259
+ return;
2260
+ }
2261
+ svgRef.current?.setPointerCapture(e.pointerId);
2262
+ const q = blueprintLocalFromWorld(px, py, bpGeom);
2263
+ const tl0 = { x: px - (q.x - bb.min.x), y: py - (q.y - bb.min.y) };
2264
+ const pointerR = Math.hypot(px - layout.cx, py - layout.cy);
2265
+ const allowInside = pointerR < layout.innerR;
2266
+ const clamped = clampTopLeftBySupport(
2267
+ tl0.x,
2268
+ tl0.y,
2269
+ { aabb: bb, support },
2270
+ layout,
2271
+ controller.state.cfg.target,
2272
+ allowInside
2273
+ );
2274
+ const pieceId = controller.spawnFromBlueprint(bp, clamped);
2275
+ const pointerAnchorNode = anchorNodeFromPoint({ x: px, y: py });
2276
+ const pointerSector = sectorIdAt(px, py) ?? void 0;
2277
+ if (tracker?.recordMouseMove) {
2278
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
2279
+ }
2280
+ if (tracker) {
2281
+ const blueprintType = "kind" in bp ? "primitive" : "composite";
2282
+ tracker.recordPickup(
2283
+ pieceId,
2284
+ bp.id,
2285
+ blueprintType,
2286
+ "blueprint",
2287
+ clamped,
2288
+ getPieceVertices(pieceId),
2289
+ void 0
2290
+ );
2291
+ }
2292
+ dragRef.current = {
2293
+ id: pieceId,
2294
+ dx: px - clamped.x,
2295
+ dy: py - clamped.y,
2296
+ tlx: clamped.x,
2297
+ tly: clamped.y,
2298
+ aabb: bb,
2299
+ minx: bb.min.x,
2300
+ miny: bb.min.y,
2301
+ support,
2302
+ fromIcon: true,
2303
+ validSnap: true,
2304
+ prevValidSnap: true,
2305
+ raf: null,
2306
+ originalPos: void 0,
2307
+ // No original position for fromIcon drags
2308
+ pointerAnchor: pointerAnchorNode,
2309
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
2310
+ pointerAnchorSectorId: pointerSector,
2311
+ snapAnchor: null,
2312
+ snapAnchorId: null,
2313
+ snapAnchorSectorId: void 0,
2314
+ snapAnchorDist: null,
2315
+ overlaps: false
2316
+ };
2317
+ updatePointerAnchor({ x: px, y: py }, pointerSector);
2318
+ updateSnapAnchor(null, void 0);
2319
+ setDraggingId(pieceId);
2320
+ force();
2321
+ scheduleFrame();
2322
+ };
2323
+ return {
2324
+ // State
2325
+ draggingId,
2326
+ dragInvalid: !!(dragRef.current && dragRef.current.validSnap === false),
2327
+ lockedPieceId,
2328
+ svgRef,
2329
+ // Handlers
2330
+ onPiecePointerDown,
2331
+ onBlueprintPointerDown,
2332
+ onPointerMove,
2333
+ onPointerUp,
2334
+ setPieceRef,
2335
+ // Click controller integration
2336
+ setDraggingId,
2337
+ // TODO: Remove this once click mode is extracted - temporary for compatibility
2338
+ dragRef
2339
+ };
2340
+ }
2341
+
2342
+ function useClickController(controller, layout, pieces, clickMode, draggingId, setDraggingId, dragRef, isInsideBadge, isSectorLocked, maybeCompleteSector, svgPoint, force, tracker) {
2343
+ const cfg = controller.state.cfg;
2344
+ const sectorIdAt = (x, y) => sectorAtPoint(x, y, layout, cfg.target);
2345
+ const getPieceVertices = (pieceId) => {
2346
+ const piece = controller.findPiece(pieceId);
2347
+ if (!piece) return [];
2348
+ const bp = controller.getBlueprint(piece.blueprintId);
2349
+ if (!bp) return [];
2350
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
2351
+ const polys = piecePolysAt(bp, bb, piece.pos);
2352
+ return polys.map((ring) => ring.map((pt) => [pt.x, pt.y]));
2353
+ };
2354
+ const getCurrentAnchorInfo = () => {
2355
+ const d = dragRef.current;
2356
+ return {
2357
+ anchorId: d?.snapAnchorId ?? d?.pointerAnchorId ?? void 0,
2358
+ wasValid: d?.validSnap !== false,
2359
+ wasOverlapping: d?.overlaps ?? false,
2360
+ anchorX: d?.pointerAnchor?.x,
2361
+ anchorY: d?.pointerAnchor?.y,
2362
+ sectorId: d?.snapAnchorSectorId ?? d?.pointerAnchorSectorId
2363
+ };
2364
+ };
2365
+ const emitClickEvent = (location, clickType, metadata) => {
2366
+ if (!tracker?.recordClickEvent) return;
2367
+ tracker.recordClickEvent(location, clickType, metadata);
2368
+ };
2369
+ const [pendingBpId, setPendingBpId] = React.useState(null);
2370
+ const [selectedPieceId, setSelectedPieceId] = React.useState(null);
2371
+ const onRootPointerDown = (e) => {
2372
+ const { x: px, y: py } = svgPoint(e.clientX, e.clientY);
2373
+ if (clickMode) {
2374
+ const secId = sectorIdAt(px, py);
2375
+ if (draggingId && dragRef.current) {
2376
+ const d = dragRef.current;
2377
+ const centerX2 = d.tlx + d.aabb.width / 2;
2378
+ const centerY2 = d.tly + d.aabb.height / 2;
2379
+ if (isInsideBadge(px, py)) {
2380
+ const piece = controller.findPiece(d.id);
2381
+ const removedFromSector = piece?.sectorId;
2382
+ const anchorInfo2 = getCurrentAnchorInfo();
2383
+ controller.remove(d.id);
2384
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
2385
+ if (tracker) {
2386
+ tracker.recordPlacedown(
2387
+ "deleted",
2388
+ removedFromSector,
2389
+ void 0,
2390
+ void 0,
2391
+ anchorInfo2.anchorId,
2392
+ anchorInfo2.wasValid,
2393
+ anchorInfo2.wasOverlapping
2394
+ );
2395
+ }
2396
+ setDraggingId(null);
2397
+ setSelectedPieceId(null);
2398
+ setPendingBpId(null);
2399
+ force();
2400
+ return;
2401
+ }
2402
+ const rFromCenter = Math.hypot(centerX2 - layout.cx, centerY2 - layout.cy);
2403
+ if (rFromCenter < layout.innerR) {
2404
+ const piece = controller.findPiece(d.id);
2405
+ const removedFromSector = piece?.sectorId;
2406
+ const anchorInfo2 = getCurrentAnchorInfo();
2407
+ controller.remove(d.id);
2408
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
2409
+ if (tracker) {
2410
+ tracker.recordPlacedown(
2411
+ "deleted",
2412
+ removedFromSector,
2413
+ void 0,
2414
+ void 0,
2415
+ anchorInfo2.anchorId,
2416
+ anchorInfo2.wasValid,
2417
+ anchorInfo2.wasOverlapping
2418
+ );
2419
+ }
2420
+ setDraggingId(null);
2421
+ setSelectedPieceId(null);
2422
+ setPendingBpId(null);
2423
+ force();
2424
+ return;
2425
+ }
2426
+ const secId2 = sectorIdAt(centerX2, centerY2);
2427
+ if (!secId2) {
2428
+ emitClickEvent(
2429
+ { x: centerX2, y: centerY2 },
2430
+ "invalid_placement",
2431
+ { invalidPlacement: { reason: "outside_bounds" } }
2432
+ );
2433
+ force();
2434
+ return;
2435
+ }
2436
+ if (isSectorLocked(secId2)) {
2437
+ emitClickEvent(
2438
+ { x: centerX2, y: centerY2 },
2439
+ "sector_complete_attempt",
2440
+ { invalidPlacement: { reason: "sector_complete", attemptedSectorId: secId2 } }
2441
+ );
2442
+ force();
2443
+ return;
2444
+ }
2445
+ if (d.validSnap === false) {
2446
+ const anchorInfo2 = getCurrentAnchorInfo();
2447
+ const invalidReason = anchorInfo2.wasOverlapping ? "overlapping" : "no_valid_anchor";
2448
+ emitClickEvent(
2449
+ { x: centerX2, y: centerY2 },
2450
+ "invalid_placement",
2451
+ { invalidPlacement: { reason: invalidReason, attemptedSectorId: secId2 } }
2452
+ );
2453
+ force();
2454
+ return;
2455
+ }
2456
+ controller.move(d.id, { x: d.tlx, y: d.tly });
2457
+ controller.drop(d.id, secId2);
2458
+ const anchorInfo = getCurrentAnchorInfo();
2459
+ const justCompleted = maybeCompleteSector(secId2);
2460
+ if (tracker) {
2461
+ tracker.recordPlacedown(
2462
+ "placed",
2463
+ secId2,
2464
+ { x: d.tlx, y: d.tly },
2465
+ getPieceVertices(d.id),
2466
+ anchorInfo.anchorId,
2467
+ anchorInfo.wasValid,
2468
+ anchorInfo.wasOverlapping,
2469
+ justCompleted
2470
+ );
2471
+ }
2472
+ setDraggingId(null);
2473
+ setSelectedPieceId(null);
2474
+ setPendingBpId(null);
2475
+ if (controller.state.blueprintView === "primitives") {
2476
+ controller.switchBlueprintView();
2477
+ }
2478
+ force();
2479
+ return;
2480
+ }
2481
+ if (selectedPieceId) {
2482
+ if (isInsideBadge(px, py)) {
2483
+ const piece = controller.findPiece(selectedPieceId);
2484
+ const removedFromSector = piece?.sectorId;
2485
+ controller.remove(selectedPieceId);
2486
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
2487
+ if (tracker) {
2488
+ tracker.recordPlacedown("deleted");
2489
+ }
2490
+ setSelectedPieceId(null);
2491
+ force();
2492
+ return;
2493
+ }
2494
+ if (secId && isSectorLocked(secId)) {
2495
+ setSelectedPieceId(null);
2496
+ force();
2497
+ return;
2498
+ }
2499
+ if (secId) {
2500
+ const p = pieces.find((pp) => pp.id === selectedPieceId);
2501
+ setSelectedPieceId(null);
2502
+ if (p) {
2503
+ const bp = controller.getBlueprint(p.blueprintId);
2504
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
2505
+ const support = computeSupportOffsets(bp, bb);
2506
+ const tl0 = { x: px - bb.width / 2, y: py - bb.height / 2 };
2507
+ const clamped = clampTopLeftBySupport(
2508
+ tl0.x,
2509
+ tl0.y,
2510
+ { aabb: { width: bb.width, height: bb.height }, support },
2511
+ layout,
2512
+ controller.state.cfg.target,
2513
+ false
2514
+ );
2515
+ const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
2516
+ if (controller.state.cfg.mode === "prep" && maxPieces > 0) {
2517
+ const sectorPieces = controller.getPiecesInSector(secId);
2518
+ const effectiveCount = sectorPieces.length - (p.sectorId === secId ? 1 : 0);
2519
+ if (effectiveCount + 1 > maxPieces) {
2520
+ force();
2521
+ return;
2522
+ }
2523
+ }
2524
+ controller.move(p.id, { x: clamped.x, y: clamped.y });
2525
+ controller.drop(p.id, secId);
2526
+ maybeCompleteSector(secId);
2527
+ force();
2528
+ }
2529
+ return;
2530
+ }
2531
+ setSelectedPieceId(null);
2532
+ force();
2533
+ return;
2534
+ }
2535
+ if (pendingBpId) {
2536
+ const secId2 = sectorIdAt(px, py);
2537
+ if (isInsideBadge(px, py) || !secId2 || isSectorLocked(secId2)) {
2538
+ setPendingBpId(null);
2539
+ force();
2540
+ return;
2541
+ }
2542
+ const bp = controller.getBlueprint(pendingBpId);
2543
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
2544
+ const support = computeSupportOffsets(bp, bb);
2545
+ const tl0 = { x: px - bb.width / 2, y: py - bb.height / 2 };
2546
+ const clamped = clampTopLeftBySupport(
2547
+ tl0.x,
2548
+ tl0.y,
2549
+ { aabb: { width: bb.width, height: bb.height }, support },
2550
+ layout,
2551
+ controller.state.cfg.target,
2552
+ false
2553
+ );
2554
+ const id = controller.spawnFromBlueprint(bp, clamped);
2555
+ const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
2556
+ if (controller.state.cfg.mode === "prep" && maxPieces > 0) {
2557
+ const sectorPieces = controller.getPiecesInSector(secId2);
2558
+ if (sectorPieces.length + 1 > maxPieces) {
2559
+ controller.remove(id);
2560
+ setPendingBpId(null);
2561
+ force();
2562
+ return;
2563
+ }
2564
+ }
2565
+ controller.drop(id, secId2);
2566
+ setPendingBpId(null);
2567
+ maybeCompleteSector(secId2);
2568
+ if (controller.state.blueprintView === "primitives") controller.switchBlueprintView();
2569
+ force();
2570
+ return;
2571
+ }
2572
+ }
2573
+ };
2574
+ return {
2575
+ // State
2576
+ pendingBpId,
2577
+ selectedPieceId,
2578
+ // Handlers
2579
+ onRootPointerDown,
2580
+ // Helper methods for integration
2581
+ clearSelection: () => {
2582
+ setSelectedPieceId(null);
2583
+ setPendingBpId(null);
2584
+ },
2585
+ setPendingBp: setPendingBpId,
2586
+ setSelectedPiece: setSelectedPieceId
2587
+ };
2588
+ }
2589
+
2590
+ class InteractionTracker {
2591
+ constructor(controller, callbacks, trialId, gameId) {
2592
+ this.gridStep = CONFIG.layout.grid.stepPx;
2593
+ // Interaction state
2594
+ this.interactionIndex = 0;
2595
+ this.currentPickup = null;
2596
+ this.previousSnapshotId = void 0;
2597
+ // Event accumulation (reset after each interaction)
2598
+ this.clickEvents = [];
2599
+ this.mouseTracking = [];
2600
+ // Interaction history (for TrialEndData)
2601
+ this.interactions = [];
2602
+ // Construction-specific tracking
2603
+ this.completionTimes = [];
2604
+ // Prep-specific tracking
2605
+ this.createdMacros = [];
2606
+ this.controller = controller;
2607
+ this.callbacks = callbacks;
2608
+ this.trialId = trialId || uuid.v4();
2609
+ this.gameId = gameId || uuid.v4();
2610
+ this.trialStartTime = Date.now();
2611
+ this.controller.setTrackingCallbacks({
2612
+ onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
2613
+ });
2614
+ }
2615
+ /**
2616
+ * Cleanup method to unregister from controller
2617
+ */
2618
+ dispose() {
2619
+ this.controller.setTrackingCallbacks(null);
2620
+ }
2621
+ // ===== Event Recording Methods =====
2622
+ // Called by UI controllers to record events
2623
+ /**
2624
+ * Record piece pickup event
2625
+ */
2626
+ recordPickup(pieceId, blueprintId, blueprintType, source, position, vertices, sectorId) {
2627
+ const anchorPosition = this.toAnchorPoint(position);
2628
+ const anchorVertices = this.toAnchorVertices(vertices);
2629
+ this.currentPickup = {
2630
+ pieceId,
2631
+ blueprintId,
2632
+ blueprintType,
2633
+ timestamp: Date.now(),
2634
+ source,
2635
+ sectorId,
2636
+ position: anchorPosition,
2637
+ vertices: anchorVertices
2638
+ };
2639
+ }
2640
+ /**
2641
+ * Record piece placedown event
2642
+ */
2643
+ recordPlacedown(outcome, sectorId, position, vertices, anchorId, wasValid, wasOverlapping, completedSector) {
2644
+ if (!this.currentPickup) {
2645
+ console.warn("[InteractionTracker] recordPlacedown called without pickup");
2646
+ return;
2647
+ }
2648
+ const interactionType = this.determineInteractionType(this.currentPickup.source, outcome);
2649
+ const placedownTimestamp = Date.now();
2650
+ const holdDuration = placedownTimestamp - this.currentPickup.timestamp;
2651
+ const postSnapshot = this.buildStateSnapshot(completedSector ? sectorId : void 0);
2652
+ const anchorPosition = position ? this.toAnchorPoint(position) : void 0;
2653
+ const anchorVertices = vertices ? this.toAnchorVertices(vertices) : void 0;
2654
+ const event = {
2655
+ // Metadata
2656
+ interactionId: uuid.v4(),
2657
+ trialId: this.trialId,
2658
+ gameId: this.gameId,
2659
+ interactionIndex: this.interactionIndex++,
2660
+ // Interaction type
2661
+ interactionType,
2662
+ // Piece information
2663
+ pieceId: this.currentPickup.pieceId,
2664
+ blueprintId: this.currentPickup.blueprintId,
2665
+ blueprintType: this.currentPickup.blueprintType,
2666
+ // Pickup event
2667
+ pickupTimestamp: this.currentPickup.timestamp,
2668
+ pickupSource: this.currentPickup.source,
2669
+ pickupSectorId: this.currentPickup.sectorId,
2670
+ pickupPosition: this.currentPickup.position,
2671
+ pickupVertices: this.currentPickup.vertices,
2672
+ // Placedown event
2673
+ placedownTimestamp,
2674
+ placedownOutcome: outcome,
2675
+ // Validation context
2676
+ wasValid: outcome === "placed" && wasValid === true,
2677
+ wasOverlapping: outcome === "placed" && wasOverlapping === true,
2678
+ // Timing
2679
+ holdDuration,
2680
+ // Events
2681
+ clickEvents: [...this.clickEvents],
2682
+ mouseTracking: [...this.mouseTracking],
2683
+ // Snapshots
2684
+ postSnapshot
2685
+ };
2686
+ if (outcome === "placed" && sectorId) {
2687
+ event.placedownSectorId = sectorId;
2688
+ }
2689
+ if (outcome === "placed" && anchorPosition) {
2690
+ event.placedownPosition = anchorPosition;
2691
+ }
2692
+ if (outcome === "placed" && anchorVertices) {
2693
+ event.placedownVertices = anchorVertices;
2694
+ }
2695
+ if (anchorId) {
2696
+ event.placedownAnchorId = anchorId;
2697
+ }
2698
+ if (this.previousSnapshotId !== void 0) {
2699
+ event.preSnapshotId = this.previousSnapshotId;
2700
+ }
2701
+ this.interactions.push(event);
2702
+ this.previousSnapshotId = postSnapshot.snapshotId;
2703
+ if (this.callbacks.onInteraction) {
2704
+ this.callbacks.onInteraction(event);
2705
+ }
2706
+ this.currentPickup = null;
2707
+ this.clickEvents = [];
2708
+ this.mouseTracking = [];
2709
+ }
2710
+ /**
2711
+ * Record click event
2712
+ */
2713
+ recordClickEvent(location, clickType, metadata) {
2714
+ const anchorLocation = this.toAnchorPoint(location);
2715
+ this.clickEvents.push({
2716
+ timestamp: Date.now(),
2717
+ location: anchorLocation,
2718
+ clickType,
2719
+ ...metadata
2720
+ });
2721
+ }
2722
+ /**
2723
+ * Record mouse movement to different anchor
2724
+ */
2725
+ recordMouseMove(anchorX, anchorY, sectorId) {
2726
+ const phase = this.currentPickup ? "while_holding" : "before_pickup";
2727
+ const anchorCoordX = this.toAnchorValue(anchorX);
2728
+ const anchorCoordY = this.toAnchorValue(anchorY);
2729
+ const lastTracking = this.mouseTracking[this.mouseTracking.length - 1];
2730
+ if (lastTracking && lastTracking.anchorX === anchorCoordX && lastTracking.anchorY === anchorCoordY) {
2731
+ return;
2732
+ }
2733
+ this.mouseTracking.push({
2734
+ timestamp: Date.now(),
2735
+ anchorX: anchorCoordX,
2736
+ anchorY: anchorCoordY,
2737
+ sectorId,
2738
+ phase
2739
+ });
2740
+ }
2741
+ /**
2742
+ * Record sector completion
2743
+ */
2744
+ recordSectorCompletion(sectorId) {
2745
+ this.completionTimes.push({
2746
+ sectorId,
2747
+ completedAt: Date.now()
2748
+ });
2749
+ }
2750
+ /**
2751
+ * Record macro creation (prep mode only)
2752
+ */
2753
+ recordMacroCreation(macro) {
2754
+ this.createdMacros.push(macro);
2755
+ }
2756
+ // ===== Trial Completion =====
2757
+ /**
2758
+ * Finalize trial and call onTrialEnd callback
2759
+ */
2760
+ finalizeTrial(endReason) {
2761
+ const trialEndTime = Date.now();
2762
+ const totalDuration = trialEndTime - this.trialStartTime;
2763
+ const finalSnapshot = this.buildStateSnapshot();
2764
+ const mode = this.controller.state.cfg.mode;
2765
+ if (mode === "construction") {
2766
+ const finalBlueprintState = this.buildFinalBlueprintState(finalSnapshot);
2767
+ const quickstashMacros = finalBlueprintState.filter((bp) => bp.blueprintType === "composite").map((bp) => ({
2768
+ id: bp.blueprintId,
2769
+ parts: bp.parts,
2770
+ // Composites always have parts
2771
+ ...bp.label && { label: bp.label }
2772
+ }));
2773
+ const data = {
2774
+ trialType: "construction",
2775
+ trialId: this.trialId,
2776
+ gameId: this.gameId,
2777
+ trialNum: 0,
2778
+ // TODO: Plugin should provide this
2779
+ trialStartTime: this.trialStartTime,
2780
+ trialEndTime,
2781
+ totalDuration,
2782
+ endReason,
2783
+ completionTimes: this.completionTimes,
2784
+ finalBlueprintState,
2785
+ quickstashMacros,
2786
+ finalSnapshot
2787
+ };
2788
+ if (this.callbacks.onTrialEnd) {
2789
+ this.callbacks.onTrialEnd(data);
2790
+ }
2791
+ } else {
2792
+ const finalMacros = this.createdMacros.length ? this.createdMacros : this.buildMacrosFromSnapshot(finalSnapshot);
2793
+ const quickstashMacros = finalMacros.map((macro) => ({
2794
+ id: macro.macroId.startsWith("comp:") ? macro.macroId : `comp:${macro.macroId}`,
2795
+ parts: macro.parts
2796
+ // Note: label not included in prep macros
2797
+ }));
2798
+ const data = {
2799
+ trialType: "prep",
2800
+ trialId: this.trialId,
2801
+ gameId: this.gameId,
2802
+ trialNum: 0,
2803
+ // TODO: Plugin should provide this
2804
+ trialStartTime: this.trialStartTime,
2805
+ trialEndTime,
2806
+ totalDuration,
2807
+ endReason: "submit",
2808
+ createdMacros: finalMacros,
2809
+ quickstashMacros,
2810
+ finalSnapshot
2811
+ };
2812
+ if (this.callbacks.onTrialEnd) {
2813
+ this.callbacks.onTrialEnd(data);
2814
+ }
2815
+ }
2816
+ }
2817
+ // ===== Helper Methods =====
2818
+ /**
2819
+ * Determine interaction type from source and outcome
2820
+ */
2821
+ determineInteractionType(source, outcome) {
2822
+ if (outcome === "deleted") return "delete";
2823
+ if (source === "blueprint") return "spawn";
2824
+ return "move";
2825
+ }
2826
+ toAnchorValue(value) {
2827
+ return Math.round(value / this.gridStep);
2828
+ }
2829
+ toAnchorPoint(point) {
2830
+ return { x: this.toAnchorValue(point.x), y: this.toAnchorValue(point.y) };
2831
+ }
2832
+ toAnchorVertices(vertices) {
2833
+ return vertices.map(
2834
+ (ring) => ring.map((vertex) => [
2835
+ this.toAnchorValue(vertex[0]),
2836
+ this.toAnchorValue(vertex[1])
2837
+ ])
2838
+ );
2839
+ }
2840
+ buildMacrosFromSnapshot(snapshot) {
2841
+ return snapshot.sectors.map((sec) => {
2842
+ const shapeOriginPositions = sec.pieces.map((piece) => {
2843
+ const bp = this.controller.getBlueprint(piece.blueprintId);
2844
+ const kind = bp && "kind" in bp ? bp.kind : piece.blueprintId;
2845
+ const actualPiece = this.controller.findPiece(piece.pieceId);
2846
+ if (!actualPiece || !bp) {
2847
+ return {
2848
+ kind,
2849
+ position: piece.position
2850
+ // Fallback to bbox position
2851
+ };
2852
+ }
2853
+ const bb = boundsOfBlueprint(bp, this.controller.getPrimitive);
2854
+ const shapeOriginPixels = {
2855
+ x: actualPiece.pos.x - bb.min.x,
2856
+ y: actualPiece.pos.y - bb.min.y
2857
+ };
2858
+ const shapeOriginAnchor = this.toAnchorPoint(shapeOriginPixels);
2859
+ return {
2860
+ kind,
2861
+ position: shapeOriginAnchor
2862
+ // Shape origin in anchor coordinates
2863
+ };
2864
+ });
2865
+ if (shapeOriginPositions.length === 0) {
2866
+ return {
2867
+ macroId: sec.sectorId,
2868
+ parts: [],
2869
+ shape: [],
2870
+ pieceCount: 0
2871
+ };
2872
+ }
2873
+ let sumX = 0, sumY = 0;
2874
+ for (const item of shapeOriginPositions) {
2875
+ sumX += item.position.x;
2876
+ sumY += item.position.y;
2877
+ }
2878
+ const centroidX = Math.round(sumX / shapeOriginPositions.length);
2879
+ const centroidY = Math.round(sumY / shapeOriginPositions.length);
2880
+ const parts = shapeOriginPositions.map((item) => ({
2881
+ kind: item.kind,
2882
+ anchorOffset: {
2883
+ x: Math.round(item.position.x - centroidX),
2884
+ y: Math.round(item.position.y - centroidY)
2885
+ }
2886
+ }));
2887
+ const shape = sec.pieces.flatMap((piece) => piece.vertices || []);
2888
+ return {
2889
+ macroId: sec.sectorId,
2890
+ parts,
2891
+ shape,
2892
+ pieceCount: sec.pieceCount
2893
+ };
2894
+ });
2895
+ }
2896
+ /**
2897
+ * Build state snapshot from current controller state
2898
+ * @param justCompletedSectorId - Sector ID that was just completed (but controller state hasn't updated yet)
2899
+ */
2900
+ buildStateSnapshot(justCompletedSectorId) {
2901
+ const snapshotId = uuid.v4();
2902
+ const timestamp = Date.now();
2903
+ const sectors = [];
2904
+ const completedSectorIds = [];
2905
+ for (const sector of this.controller.state.cfg.sectors) {
2906
+ const sectorState = this.controller.state.sectors[sector.id];
2907
+ const completed = sector.id === justCompletedSectorId || !!sectorState?.completedAt;
2908
+ if (completed) {
2909
+ completedSectorIds.push(sector.id);
2910
+ }
2911
+ const pieces = (sectorState?.pieces || []).map((p) => ({
2912
+ pieceId: p.id,
2913
+ blueprintId: p.blueprintId,
2914
+ blueprintType: this.getBlueprintType(p.blueprintId),
2915
+ position: this.toAnchorPoint(p.pos),
2916
+ vertices: this.getPieceVertices(p.id)
2917
+ }));
2918
+ const snapshot = {
2919
+ sectorId: sector.id,
2920
+ completed,
2921
+ pieceCount: pieces.length,
2922
+ pieces
2923
+ };
2924
+ if (sectorState?.completedAt !== void 0) {
2925
+ snapshot.completedAt = sectorState.completedAt;
2926
+ }
2927
+ sectors.push(snapshot);
2928
+ }
2929
+ const sectorTangramMap = this.controller.state.cfg.sectors.map((s) => ({
2930
+ sectorId: s.id,
2931
+ tangramId: s.id
2932
+ // In our system, sector ID == tangram ID
2933
+ }));
2934
+ const blueprintOrder = {
2935
+ primitives: this.controller.state.primitives.map((bp) => bp.id),
2936
+ quickstash: this.controller.state.quickstash.map((bp) => bp.id)
2937
+ };
2938
+ return {
2939
+ snapshotId,
2940
+ timestamp,
2941
+ sectors,
2942
+ completedSectorIds,
2943
+ sectorTangramMap,
2944
+ blueprintOrder
2945
+ };
2946
+ }
2947
+ /**
2948
+ * Get blueprint type from blueprint ID
2949
+ */
2950
+ getBlueprintType(blueprintId) {
2951
+ const bp = this.controller.getBlueprint(blueprintId);
2952
+ if (!bp) return "primitive";
2953
+ return "kind" in bp ? "primitive" : "composite";
2954
+ }
2955
+ /**
2956
+ * Get piece vertices in world space
2957
+ */
2958
+ getPieceVertices(pieceId) {
2959
+ const piece = this.controller.findPiece(pieceId);
2960
+ if (!piece) return [];
2961
+ const bp = this.controller.getBlueprint(piece.blueprintId);
2962
+ if (!bp) return [];
2963
+ const bb = boundsOfBlueprint(bp, (k) => this.controller.getPrimitive(k));
2964
+ const polys = piecePolysAt(bp, bb, piece.pos) ?? [];
2965
+ const asNumbers = polys.map((ring) => ring.map((pt) => [pt.x, pt.y]));
2966
+ return this.toAnchorVertices(asNumbers);
2967
+ }
2968
+ /**
2969
+ * Build final blueprint state (usage counts + definitions)
2970
+ */
2971
+ buildFinalBlueprintState(snapshot) {
2972
+ const counts = /* @__PURE__ */ new Map();
2973
+ for (const sector of snapshot.sectors) {
2974
+ for (const piece of sector.pieces) {
2975
+ if (!counts.has(piece.blueprintId)) {
2976
+ counts.set(piece.blueprintId, {
2977
+ blueprintType: piece.blueprintType,
2978
+ totalPieces: 0,
2979
+ bySector: /* @__PURE__ */ new Map()
2980
+ });
2981
+ }
2982
+ const entry = counts.get(piece.blueprintId);
2983
+ entry.totalPieces++;
2984
+ entry.bySector.set(sector.sectorId, (entry.bySector.get(sector.sectorId) || 0) + 1);
2985
+ }
2986
+ }
2987
+ for (const primitive of this.controller.state.primitives) {
2988
+ if (!counts.has(primitive.id)) {
2989
+ counts.set(primitive.id, {
2990
+ blueprintType: "primitive",
2991
+ totalPieces: 0,
2992
+ bySector: /* @__PURE__ */ new Map()
2993
+ });
2994
+ }
2995
+ }
2996
+ for (const composite of this.controller.state.quickstash) {
2997
+ if (!counts.has(composite.id)) {
2998
+ counts.set(composite.id, {
2999
+ blueprintType: "composite",
3000
+ totalPieces: 0,
3001
+ bySector: /* @__PURE__ */ new Map()
3002
+ });
3003
+ }
3004
+ }
3005
+ return Array.from(counts.entries()).map(([blueprintId, data]) => {
3006
+ const blueprint = this.controller.getBlueprint(blueprintId);
3007
+ const result = {
3008
+ blueprintId,
3009
+ blueprintType: data.blueprintType,
3010
+ totalPieces: data.totalPieces,
3011
+ bySector: Array.from(data.bySector.entries()).map(([sectorId, count]) => ({
3012
+ sectorId,
3013
+ count
3014
+ }))
3015
+ };
3016
+ if (blueprint) {
3017
+ if (data.blueprintType === "composite" && "parts" in blueprint) {
3018
+ result.parts = blueprint.parts.map((part) => ({
3019
+ kind: part.kind,
3020
+ anchorOffset: {
3021
+ x: Math.round(part.offset.x / this.gridStep),
3022
+ y: Math.round(part.offset.y / this.gridStep)
3023
+ }
3024
+ }));
3025
+ if (blueprint.shape) {
3026
+ result.shape = blueprint.shape.map(
3027
+ (poly) => poly.map((vertex) => ({
3028
+ x: Math.round(vertex.x / this.gridStep),
3029
+ y: Math.round(vertex.y / this.gridStep)
3030
+ }))
3031
+ );
3032
+ }
3033
+ if (blueprint.label) {
3034
+ result.label = blueprint.label;
3035
+ }
3036
+ } else if (data.blueprintType === "primitive" && "kind" in blueprint) {
3037
+ if (blueprint.shape) {
3038
+ result.shape = blueprint.shape.map(
3039
+ (poly) => poly.map((vertex) => ({
3040
+ x: Math.round(vertex.x / this.gridStep),
3041
+ y: Math.round(vertex.y / this.gridStep)
3042
+ }))
3043
+ );
3044
+ }
3045
+ result.label = blueprint.kind;
3046
+ }
3047
+ }
3048
+ return result;
3049
+ });
3050
+ }
3051
+ }
3052
+
3053
+ function GameBoard(props) {
3054
+ const {
3055
+ sectors,
3056
+ quickstash,
3057
+ primitives,
3058
+ layout: layoutMode,
3059
+ target,
3060
+ input,
3061
+ timeLimitMs,
3062
+ maxQuickstashSlots,
3063
+ maxCompositeSize,
3064
+ mode,
3065
+ width: _width,
3066
+ height: _height,
3067
+ onSectorComplete,
3068
+ onPiecePlace,
3069
+ onPieceRemove,
3070
+ onInteraction,
3071
+ onTrialEnd,
3072
+ onControllerReady
3073
+ } = props;
3074
+ const controller = React.useMemo(() => {
3075
+ const gameConfig = {
3076
+ n: sectors.length,
3077
+ layout: layoutMode,
3078
+ target,
3079
+ input,
3080
+ timeLimitMs,
3081
+ maxQuickstashSlots,
3082
+ ...maxCompositeSize !== void 0 && { maxCompositeSize },
3083
+ mode: mode || "construction",
3084
+ // Default to construction mode for backwards compatibility
3085
+ ...props.minPiecesPerMacro !== void 0 && { minPiecesPerMacro: props.minPiecesPerMacro },
3086
+ ...props.requireAllSlots !== void 0 && { requireAllSlots: props.requireAllSlots }
3087
+ };
3088
+ return new BaseGameController(
3089
+ sectors,
3090
+ quickstash,
3091
+ primitives,
3092
+ gameConfig
3093
+ );
3094
+ }, [sectors, quickstash, primitives, layoutMode, target, input, timeLimitMs, maxQuickstashSlots, maxCompositeSize, mode, props.minPiecesPerMacro, props.requireAllSlots]);
3095
+ const tracker = React.useMemo(() => {
3096
+ if (!onInteraction && !onTrialEnd) return null;
3097
+ const callbacks = {};
3098
+ if (onInteraction) callbacks.onInteraction = onInteraction;
3099
+ if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3100
+ return new InteractionTracker(controller, callbacks);
3101
+ }, [controller, onInteraction, onTrialEnd]);
3102
+ React.useEffect(() => {
3103
+ if (onControllerReady) {
3104
+ onControllerReady(controller);
3105
+ }
3106
+ }, [controller, onControllerReady]);
3107
+ React.useEffect(() => {
3108
+ return () => {
3109
+ if (tracker) {
3110
+ tracker.dispose();
3111
+ }
3112
+ };
3113
+ }, [tracker]);
3114
+ const { layout, viewBox } = React.useMemo(() => {
3115
+ const nSectors = sectors.length;
3116
+ const sectorIds = sectors.map((s) => s.id);
3117
+ const masksPerSector = sectors.map((s) => s.silhouette.mask ?? []);
3118
+ const logicalBox = solveLogicalBox({
3119
+ n: nSectors,
3120
+ layoutMode,
3121
+ target,
3122
+ qsMaxSlots: maxQuickstashSlots,
3123
+ primitivesSlots: primitives.length,
3124
+ layoutPadPx: CONFIG.layout.paddingPx,
3125
+ masks: masksPerSector
3126
+ });
3127
+ const layout2 = computeCircleLayout(
3128
+ { w: logicalBox.LOGICAL_W, h: logicalBox.LOGICAL_H },
3129
+ nSectors,
3130
+ sectorIds,
3131
+ layoutMode,
3132
+ target,
3133
+ {
3134
+ qsMaxSlots: maxQuickstashSlots,
3135
+ primitivesSlots: primitives.length,
3136
+ masks: masksPerSector
3137
+ }
3138
+ );
3139
+ return {
3140
+ layout: layout2,
3141
+ viewBox: { w: logicalBox.LOGICAL_W, h: logicalBox.LOGICAL_H }
3142
+ };
3143
+ }, [sectors, layoutMode, target, maxQuickstashSlots, primitives.length]);
3144
+ const [, force] = React.useReducer((x) => x + 1, 0);
3145
+ React.useEffect(() => {
3146
+ if (onControllerReady) {
3147
+ onControllerReady(controller, layout, force);
3148
+ }
3149
+ }, [controller, layout, onControllerReady, force]);
3150
+ const [gameCompleted, setGameCompleted] = React.useState(false);
3151
+ React.useEffect(() => {
3152
+ const checkGameCompletion = () => {
3153
+ const allSectorsComplete = sectors.every(
3154
+ (s) => controller.isSectorCompleted(s.id)
3155
+ );
3156
+ if (allSectorsComplete && !gameCompleted) {
3157
+ setGameCompleted(true);
3158
+ if (tracker) {
3159
+ tracker.finalizeTrial("auto_complete");
3160
+ }
3161
+ }
3162
+ };
3163
+ checkGameCompletion();
3164
+ }, [controller.updateCount, sectors, gameCompleted, controller, tracker]);
3165
+ const handleSectorComplete = React.useCallback((sectorId) => {
3166
+ if (onSectorComplete) {
3167
+ onSectorComplete(sectorId, controller.snapshot());
3168
+ }
3169
+ }, [onSectorComplete, controller]);
3170
+ const eventCallbacks = React.useMemo(() => ({
3171
+ onSectorComplete: handleSectorComplete,
3172
+ onPiecePlace: onPiecePlace || (() => {
3173
+ }),
3174
+ onPieceRemove: onPieceRemove || (() => {
3175
+ })
3176
+ }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3177
+ const getGameboardStyle = () => {
3178
+ const baseStyle = {
3179
+ margin: "0 auto",
3180
+ display: "flex",
3181
+ alignItems: "center",
3182
+ justifyContent: "center",
3183
+ position: "relative"
3184
+ };
3185
+ if (layoutMode === "circle") {
3186
+ const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
3187
+ return {
3188
+ ...baseStyle,
3189
+ width: `${size}px`,
3190
+ height: `${size}px`
3191
+ };
3192
+ } else {
3193
+ const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3194
+ const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
3195
+ return {
3196
+ ...baseStyle,
3197
+ width: `${maxWidth}px`,
3198
+ height: `${maxHeight}px`
3199
+ };
3200
+ }
3201
+ };
3202
+ const getSvgDimensions = () => {
3203
+ if (layoutMode === "circle") {
3204
+ const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
3205
+ return { width: size, height: size };
3206
+ } else {
3207
+ const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3208
+ const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
3209
+ return { width: maxWidth, height: maxHeight };
3210
+ }
3211
+ };
3212
+ const svgDimensions = getSvgDimensions();
3213
+ React.useEffect(() => {
3214
+ if (controller.state.cfg.mode !== "construction") return;
3215
+ if (!timeLimitMs || timeLimitMs <= 0) return;
3216
+ if (gameCompleted) return;
3217
+ const timeoutId = window.setTimeout(() => {
3218
+ if (gameCompleted) return;
3219
+ if (!controller.state.endedAt) {
3220
+ controller.state.endedAt = performance.now();
3221
+ force();
3222
+ }
3223
+ tracker?.finalizeTrial("timeout");
3224
+ setGameCompleted(true);
3225
+ }, timeLimitMs);
3226
+ return () => {
3227
+ window.clearTimeout(timeoutId);
3228
+ };
3229
+ }, [controller, timeLimitMs, gameCompleted, tracker, force]);
3230
+ const isSectorLocked = (id) => controller.isSectorCompleted(id);
3231
+ const { pieces, pieceById, getSectorPiecePolysCached } = usePieceState(controller);
3232
+ const clickMode = controller.state.cfg.input === "click";
3233
+ const badgeR = Math.max(CONFIG.size.centerBadge.minPx, layout.innerR * CONFIG.size.centerBadge.fractionOfOuterR);
3234
+ const badgeCenter = controller.state.cfg.layout === "semicircle" ? { x: layout.cx, y: layout.cy - badgeR - CONFIG.size.centerBadge.marginPx } : { x: layout.cx, y: layout.cy };
3235
+ const isInsideBadge = (px, py) => Math.hypot(px - badgeCenter.x, py - badgeCenter.y) <= badgeR;
3236
+ const allMasks = React.useMemo(() => {
3237
+ return controller.state.cfg.sectors.map((sec) => sec.silhouette.mask ?? []);
3238
+ }, [controller.state.cfg.sectors]);
3239
+ const scaleS = React.useMemo(() => {
3240
+ const u = inferUnitFromPolys$1(allMasks.flat());
3241
+ return u ? CONFIG.layout.grid.unitPx / u : 1;
3242
+ }, [allMasks]);
3243
+ const { anchorDots } = useAnchorGrid(controller, layout, scaleS);
3244
+ const placedSilBySector = React.useMemo(() => {
3245
+ const m = /* @__PURE__ */ new Map();
3246
+ for (const s of layout.sectors) {
3247
+ const mask = controller.state.cfg.sectors.find((ss) => ss.id === s.id)?.silhouette.mask ?? [];
3248
+ if (!mask?.length) continue;
3249
+ const rect = rectForBand(layout, s, "silhouette", 1);
3250
+ const placed = placeSilhouetteGridAlignedAsPolys(mask, scaleS, { cx: rect.cx, cy: rect.cy });
3251
+ m.set(s.id, placed);
3252
+ }
3253
+ return m;
3254
+ }, [layout, scaleS, controller.state.cfg.sectors]);
3255
+ const maybeCompleteSector = (secId) => {
3256
+ if (!secId) return false;
3257
+ if (isSectorLocked(secId)) return false;
3258
+ if (controller.state.cfg.target === "silhouette") {
3259
+ const sil = placedSilBySector.get(secId) ?? [];
3260
+ if (!sil.length) return false;
3261
+ const piecePolys = getSectorPiecePolysCached(secId);
3262
+ if (anchorsSilhouetteComplete(sil, piecePolys)) {
3263
+ controller.markSectorCompleted(secId);
3264
+ return true;
3265
+ }
3266
+ } else {
3267
+ const raw = controller.state.cfg.sectors.find((ss) => ss.id === secId)?.silhouette.mask ?? [];
3268
+ if (!raw.length) return false;
3269
+ const scaledSil = scalePolys(raw, scaleS);
3270
+ const piecePolys = getSectorPiecePolysCached(secId);
3271
+ if (anchorsWorkspaceComplete(scaledSil, piecePolys)) {
3272
+ controller.markSectorCompleted(secId);
3273
+ return true;
3274
+ }
3275
+ }
3276
+ return false;
3277
+ };
3278
+ const dragController = useDragController(
3279
+ controller,
3280
+ layout,
3281
+ pieces,
3282
+ pieceById,
3283
+ anchorDots,
3284
+ placedSilBySector,
3285
+ isSectorLocked,
3286
+ maybeCompleteSector,
3287
+ force,
3288
+ void 0,
3289
+ // clickController - will be set later
3290
+ tracker
3291
+ // Pass tracker for data collection
3292
+ );
3293
+ const {
3294
+ draggingId,
3295
+ dragInvalid,
3296
+ svgRef,
3297
+ onPiecePointerDown,
3298
+ onBlueprintPointerDown,
3299
+ onPointerMove,
3300
+ onPointerUp,
3301
+ setPieceRef,
3302
+ setDraggingId,
3303
+ lockedPieceId,
3304
+ dragRef
3305
+ // TODO: Remove once click mode is extracted
3306
+ } = dragController;
3307
+ const svgPoint = (clientX, clientY) => {
3308
+ const svg = svgRef.current;
3309
+ const pt = svg.createSVGPoint();
3310
+ pt.x = clientX;
3311
+ pt.y = clientY;
3312
+ const ctm = svg.getScreenCTM();
3313
+ if (!ctm) return { x: 0, y: 0 };
3314
+ const sp = pt.matrixTransform(ctm.inverse());
3315
+ return { x: sp.x, y: sp.y };
3316
+ };
3317
+ const clickController = useClickController(
3318
+ controller,
3319
+ layout,
3320
+ pieces,
3321
+ clickMode,
3322
+ draggingId,
3323
+ setDraggingId,
3324
+ dragRef,
3325
+ isInsideBadge,
3326
+ isSectorLocked,
3327
+ maybeCompleteSector,
3328
+ svgPoint,
3329
+ force,
3330
+ tracker
3331
+ // Pass tracker for data collection
3332
+ );
3333
+ const {
3334
+ selectedPieceId,
3335
+ onRootPointerDown
3336
+ } = clickController;
3337
+ const onCenterBadgePointerDown = (e) => {
3338
+ if (draggingId) return;
3339
+ const { x, y } = svgPoint(e.clientX, e.clientY);
3340
+ if (controller.state.cfg.mode === "prep") {
3341
+ if (!controller.isSubmitEnabled() || gameCompleted) {
3342
+ e.stopPropagation();
3343
+ return;
3344
+ }
3345
+ setGameCompleted(true);
3346
+ if (tracker) {
3347
+ tracker.finalizeTrial("submit");
3348
+ }
3349
+ e.stopPropagation();
3350
+ return;
3351
+ }
3352
+ if (tracker?.recordClickEvent) {
3353
+ const from = controller.state.blueprintView;
3354
+ const to = from === "primitives" ? "quickstash" : "primitives";
3355
+ tracker.recordClickEvent({ x, y }, "blueprint_view_switch", {
3356
+ blueprintViewSwitch: { from, to }
3357
+ });
3358
+ }
3359
+ controller.switchBlueprintView();
3360
+ force();
3361
+ e.stopPropagation();
3362
+ };
3363
+ return /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3364
+ BoardView,
3365
+ {
3366
+ controller,
3367
+ layout,
3368
+ viewBox,
3369
+ width: svgDimensions.width,
3370
+ height: svgDimensions.height,
3371
+ badgeR,
3372
+ badgeCenter,
3373
+ placedSilBySector,
3374
+ anchorDots,
3375
+ pieces,
3376
+ clickMode,
3377
+ draggingId,
3378
+ selectedPieceId,
3379
+ dragInvalid,
3380
+ lockedPieceId,
3381
+ svgRef,
3382
+ setPieceRef,
3383
+ onPiecePointerDown,
3384
+ onBlueprintPointerDown,
3385
+ onRootPointerDown,
3386
+ onPointerMove,
3387
+ onPointerUp,
3388
+ onCenterBadgePointerDown,
3389
+ ...eventCallbacks
3390
+ }
3391
+ ));
3392
+ }
3393
+
3394
+ const U = 40;
3395
+ const HALFUNIT = U / 2;
3396
+ const DIAGONAL = U * Math.SQRT2;
3397
+ const HALFDIAGONAL = DIAGONAL / 2;
3398
+ const P = (x, y) => ({ x, y });
3399
+ function rotLeft(arr, k) {
3400
+ const n = arr.length;
3401
+ if (n === 0) return [];
3402
+ const s = (k % n + n) % n;
3403
+ return arr.slice(s).concat(arr.slice(0, s));
3404
+ }
3405
+ function nextPoint(x0, y0, x1, y1, interiorDeg, dist) {
3406
+ const phi = Math.atan2(y0 - y1, x0 - x1);
3407
+ const theta = phi - interiorDeg * Math.PI / 180;
3408
+ return P(x1 + dist * Math.cos(theta), y1 + dist * Math.sin(theta));
3409
+ }
3410
+ function constructFromSpec(sideLens, angles, firstEdgeUnits) {
3411
+ let pts;
3412
+ if (firstEdgeUnits) {
3413
+ const a = firstEdgeUnits[0];
3414
+ const b = firstEdgeUnits[1];
3415
+ const A = P(-a.x * U, a.y * U);
3416
+ const B = P(-b.x * U, b.y * U);
3417
+ pts = [A, B];
3418
+ } else {
3419
+ const firstSideLen = sideLens[0];
3420
+ if (firstSideLen === void 0) throw new Error("No side lengths provided");
3421
+ pts = [P(0, 0), P(firstSideLen, 0)];
3422
+ }
3423
+ const sidesRot = rotLeft(sideLens, 1);
3424
+ const angRot = angles;
3425
+ for (let i = 1; i < sideLens.length - 1; i++) {
3426
+ const last = pts[i - 1];
3427
+ const curr = pts[i];
3428
+ if (!last || !curr) continue;
3429
+ const sLen = sidesRot[i - 1];
3430
+ const aDeg = angRot[i - 1];
3431
+ if (sLen === void 0 || aDeg === void 0) continue;
3432
+ pts.push(nextPoint(last.x, last.y, curr.x, curr.y, aDeg, sLen));
3433
+ }
3434
+ return pts;
3435
+ }
3436
+ const FIRST_EDGES_UNITS = {
3437
+ small_triangle: [P(0, 0), P(0.5, 0.5)],
3438
+ parallelogram: [P(0, 0), P(0.5, 0)],
3439
+ large_triangle: [P(0, 0), P(0.5, -0.5)],
3440
+ med_triangle: [P(0, 0), P(0.5, 0)],
3441
+ square: [P(0, 0), P(0.5, 0)]
3442
+ };
3443
+ const PRIMITIVE_BLUEPRINTS_CACHE = (() => {
3444
+ const specs = [
3445
+ {
3446
+ id: "prim:square",
3447
+ kind: "square",
3448
+ sideLens: [HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT],
3449
+ angles: [180, 90, 180, 90, 180, 90, 180, 90],
3450
+ color: "#f43f5e"
3451
+ },
3452
+ {
3453
+ id: "prim:small",
3454
+ kind: "small_triangle",
3455
+ sideLens: [HALFDIAGONAL, HALFDIAGONAL, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT],
3456
+ angles: [180, 45, 180, 90, 180, 45],
3457
+ color: "#f59e0b"
3458
+ },
3459
+ {
3460
+ id: "prim:parallelogram",
3461
+ kind: "parallelogram",
3462
+ sideLens: [HALFUNIT, HALFUNIT, HALFDIAGONAL, HALFDIAGONAL, HALFUNIT, HALFUNIT, HALFDIAGONAL, HALFDIAGONAL],
3463
+ angles: [180, 135, 180, 45, 180, 135, 180, 45],
3464
+ color: "#10b981"
3465
+ },
3466
+ {
3467
+ id: "prim:med",
3468
+ kind: "med_triangle",
3469
+ sideLens: [HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFDIAGONAL, HALFDIAGONAL, HALFDIAGONAL, HALFDIAGONAL],
3470
+ angles: [180, 180, 180, 45, 180, 90, 180, 45],
3471
+ color: "#3b82f6"
3472
+ },
3473
+ {
3474
+ id: "prim:large",
3475
+ kind: "large_triangle",
3476
+ sideLens: [
3477
+ HALFDIAGONAL,
3478
+ HALFDIAGONAL,
3479
+ HALFDIAGONAL,
3480
+ HALFDIAGONAL,
3481
+ HALFUNIT,
3482
+ HALFUNIT,
3483
+ HALFUNIT,
3484
+ HALFUNIT,
3485
+ HALFUNIT,
3486
+ HALFUNIT,
3487
+ HALFUNIT,
3488
+ HALFUNIT
3489
+ ],
3490
+ angles: [
3491
+ 180,
3492
+ 180,
3493
+ 180,
3494
+ 45,
3495
+ 180,
3496
+ 180,
3497
+ 180,
3498
+ 90,
3499
+ 180,
3500
+ 180,
3501
+ 180,
3502
+ 45
3503
+ ],
3504
+ color: "#8b5cf6"
3505
+ }
3506
+ ];
3507
+ return specs.map(({ id, kind, sideLens, angles, color }) => {
3508
+ const firstEdge = FIRST_EDGES_UNITS[kind];
3509
+ const verts = constructFromSpec(sideLens, angles, firstEdge);
3510
+ return {
3511
+ id,
3512
+ kind,
3513
+ shape: [verts],
3514
+ colorHint: color
3515
+ };
3516
+ });
3517
+ })();
3518
+ const PRIMITIVE_BLUEPRINTS = PRIMITIVE_BLUEPRINTS_CACHE;
3519
+
3520
+ function convertAnchorCompositeToPixels(anchorComposite, primsByKind, gridStepPx = CONFIG.layout.grid.stepPx) {
3521
+ const { id, parts, label } = anchorComposite;
3522
+ const pixelParts = parts.map((p) => ({
3523
+ kind: p.kind,
3524
+ offset: {
3525
+ x: p.anchorOffset.x * gridStepPx,
3526
+ y: p.anchorOffset.y * gridStepPx
3527
+ }
3528
+ }));
3529
+ const shape = [];
3530
+ for (const p of parts) {
3531
+ const prim = primsByKind.get(p.kind);
3532
+ if (!prim) continue;
3533
+ const pixelOffset = {
3534
+ x: p.anchorOffset.x * gridStepPx,
3535
+ y: p.anchorOffset.y * gridStepPx
3536
+ };
3537
+ for (const poly of prim.shape) {
3538
+ shape.push(poly.map((v) => ({ x: v.x + pixelOffset.x, y: v.y + pixelOffset.y })));
3539
+ }
3540
+ }
3541
+ return { id, parts: pixelParts, shape, label: label ?? `Composite-${id}` };
3542
+ }
3543
+
3544
+ function startPrepTrial(display_element, params, jsPsych) {
3545
+ const {
3546
+ numQuickstashSlots,
3547
+ maxPiecesPerMacro,
3548
+ minPiecesPerMacro,
3549
+ inputMode,
3550
+ layoutMode,
3551
+ requireAllSlots,
3552
+ quickstashMacros,
3553
+ onInteraction,
3554
+ onTrialEnd
3555
+ } = params;
3556
+ const prepSectors = Array.from({ length: numQuickstashSlots }, (_, i) => ({
3557
+ id: `prep-sector-${i}`,
3558
+ silhouette: {
3559
+ id: `prep-silhouette-${i}`,
3560
+ mask: []
3561
+ }
3562
+ }));
3563
+ const root = client.createRoot(display_element);
3564
+ const handleControllerReady = (controller, layout, force) => {
3565
+ if (quickstashMacros && quickstashMacros.length > 0 && layout) {
3566
+ const primsByKind = /* @__PURE__ */ new Map();
3567
+ PRIMITIVE_BLUEPRINTS.forEach((p) => primsByKind.set(p.kind, p));
3568
+ quickstashMacros.forEach((anchorComposite, macroIndex) => {
3569
+ const sectorId = `prep-sector-${macroIndex}`;
3570
+ const compositeBlueprint = convertAnchorCompositeToPixels(
3571
+ anchorComposite,
3572
+ primsByKind,
3573
+ CONFIG.layout.grid.stepPx
3574
+ );
3575
+ const sectorGeom = layout.sectors.find((s) => s.id === sectorId);
3576
+ if (!sectorGeom) return;
3577
+ const sectorRect = rectForBand(layout, sectorGeom, "workspace", 1);
3578
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3579
+ compositeBlueprint.parts.forEach((part) => {
3580
+ const primitiveBlueprint = primsByKind.get(part.kind);
3581
+ if (!primitiveBlueprint) return;
3582
+ const bb = boundsOfBlueprint(primitiveBlueprint, controller.getPrimitive);
3583
+ const pieceMinX = part.offset.x + bb.min.x;
3584
+ const pieceMinY = part.offset.y + bb.min.y;
3585
+ const pieceMaxX = part.offset.x + bb.max.x;
3586
+ const pieceMaxY = part.offset.y + bb.max.y;
3587
+ minX = Math.min(minX, pieceMinX);
3588
+ minY = Math.min(minY, pieceMinY);
3589
+ maxX = Math.max(maxX, pieceMaxX);
3590
+ maxY = Math.max(maxY, pieceMaxY);
3591
+ });
3592
+ const compositeCenterX = minX + (maxX - minX) / 2;
3593
+ const compositeCenterY = minY + (maxY - minY) / 2;
3594
+ compositeBlueprint.parts.forEach((part) => {
3595
+ const primitiveBlueprint = primsByKind.get(part.kind);
3596
+ if (!primitiveBlueprint) {
3597
+ console.warn(`Unknown primitive kind: ${part.kind}`);
3598
+ return;
3599
+ }
3600
+ const bb = boundsOfBlueprint(primitiveBlueprint, controller.getPrimitive);
3601
+ const compositeBBoxMinX = part.offset.x + bb.min.x;
3602
+ const compositeBBoxMinY = part.offset.y + bb.min.y;
3603
+ const worldPosX = sectorRect.cx + (compositeBBoxMinX - compositeCenterX);
3604
+ const worldPosY = sectorRect.cy + (compositeBBoxMinY - compositeCenterY);
3605
+ const gridStep = CONFIG.layout.grid.stepPx;
3606
+ const alignedX = Math.round(worldPosX / gridStep) * gridStep;
3607
+ const alignedY = Math.round(worldPosY / gridStep) * gridStep;
3608
+ const pieceId = controller.spawnFromBlueprint(
3609
+ primitiveBlueprint,
3610
+ { x: alignedX, y: alignedY }
3611
+ );
3612
+ controller.drop(pieceId, sectorId);
3613
+ });
3614
+ });
3615
+ if (force) {
3616
+ force();
3617
+ }
3618
+ }
3619
+ };
3620
+ root.render(React.createElement(GameBoard, {
3621
+ sectors: prepSectors,
3622
+ quickstash: [],
3623
+ // No pre-made macros
3624
+ primitives: PRIMITIVE_BLUEPRINTS,
3625
+ layout: layoutMode,
3626
+ target: "workspace",
3627
+ // Pieces go in sectors
3628
+ input: inputMode,
3629
+ timeLimitMs: 0,
3630
+ // No time limit for prep
3631
+ maxQuickstashSlots: 0,
3632
+ // Primitives only in center
3633
+ maxCompositeSize: maxPiecesPerMacro,
3634
+ mode: "prep",
3635
+ // Enable prep-specific behavior
3636
+ minPiecesPerMacro,
3637
+ requireAllSlots,
3638
+ onControllerReady: handleControllerReady,
3639
+ ...onInteraction && { onInteraction },
3640
+ ...onTrialEnd && { onTrialEnd }
3641
+ }));
3642
+ return { root, display_element, jsPsych };
3643
+ }
3644
+
3645
+ const info = {
3646
+ name: "tangram-prep",
3647
+ version: "1.0.0",
3648
+ parameters: {
3649
+ /** Number of quickstash slots for macro creation */
3650
+ num_quickstash_slots: {
3651
+ type: jspsych.ParameterType.INT,
3652
+ default: void 0
3653
+ },
3654
+ /** Maximum pieces allowed per macro */
3655
+ max_pieces_per_macro: {
3656
+ type: jspsych.ParameterType.INT,
3657
+ default: 2
3658
+ },
3659
+ /** Minimum pieces required per macro */
3660
+ min_pieces_per_macro: {
3661
+ type: jspsych.ParameterType.INT,
3662
+ default: 2
3663
+ },
3664
+ /** Input mode: click or drag */
3665
+ input: {
3666
+ type: jspsych.ParameterType.STRING,
3667
+ default: "click"
3668
+ },
3669
+ /** Layout mode: circle or semicircle */
3670
+ layout: {
3671
+ type: jspsych.ParameterType.STRING,
3672
+ default: "semicircle"
3673
+ },
3674
+ /** Whether all slots must be filled to complete trial */
3675
+ require_all_slots: {
3676
+ type: jspsych.ParameterType.BOOL,
3677
+ default: void 0
3678
+ },
3679
+ /** Array of pre-made macro pieces to edit */
3680
+ quickstash_macros: {
3681
+ type: jspsych.ParameterType.COMPLEX,
3682
+ default: [],
3683
+ description: "Array of AnchorComposite objects to edit as primitive pieces"
3684
+ },
3685
+ /** Callback fired after each interaction (optional analytics hook) */
3686
+ onInteraction: {
3687
+ type: jspsych.ParameterType.FUNCTION,
3688
+ default: void 0
3689
+ },
3690
+ /** Callback fired when prep trial ends */
3691
+ onTrialEnd: {
3692
+ type: jspsych.ParameterType.FUNCTION,
3693
+ default: void 0
3694
+ }
3695
+ },
3696
+ data: {
3697
+ /** Completion status */
3698
+ completed: {
3699
+ type: jspsych.ParameterType.BOOL
3700
+ }
3701
+ },
3702
+ citations: ""
3703
+ };
3704
+ class TangramPrepPlugin {
3705
+ constructor(jsPsych) {
3706
+ this.jsPsych = jsPsych;
3707
+ }
3708
+ static {
3709
+ this.info = info;
3710
+ }
3711
+ /**
3712
+ * Launches the trial by invoking startPrepTrial
3713
+ * with the display element, parameters, and jsPsych instance.
3714
+ */
3715
+ trial(display_element, trial) {
3716
+ const wrappedOnTrialEnd = (data) => {
3717
+ if (trial.onTrialEnd) {
3718
+ trial.onTrialEnd(data);
3719
+ }
3720
+ const reactContext = display_element.__reactContext;
3721
+ if (reactContext?.root) {
3722
+ reactContext.root.unmount();
3723
+ }
3724
+ display_element.innerHTML = "";
3725
+ this.jsPsych.finishTrial(data);
3726
+ };
3727
+ const params = {
3728
+ numQuickstashSlots: trial.num_quickstash_slots || 4,
3729
+ maxPiecesPerMacro: trial.max_pieces_per_macro,
3730
+ minPiecesPerMacro: trial.min_pieces_per_macro,
3731
+ inputMode: trial.input,
3732
+ layoutMode: trial.layout,
3733
+ requireAllSlots: trial.require_all_slots,
3734
+ quickstashMacros: trial.quickstash_macros,
3735
+ onInteraction: trial.onInteraction,
3736
+ onTrialEnd: wrappedOnTrialEnd
3737
+ };
3738
+ const { root, display_element: element, jsPsych } = startPrepTrial(display_element, params, this.jsPsych);
3739
+ element.__reactContext = { root, jsPsych };
3740
+ }
3741
+ }
3742
+
3743
+ module.exports = TangramPrepPlugin;
3744
+ //# sourceMappingURL=index.cjs.map