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