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,1051 @@
1
+ import React from "react";
2
+ import { BaseGameController } from "@/core/engine/state/BaseGameController";
3
+ import type { CircleLayout } from "@/core/domain/layout";
4
+ import type { Blueprint } from "@/core/domain/types";
5
+ import { sectorAtPoint } from "@/core/domain/layout";
6
+ import {
7
+ boundsOfBlueprint,
8
+ computeSupportOffsets,
9
+ clampTopLeftBySupport,
10
+ polysOverlap,
11
+ piecePolysAt
12
+ } from "@/core/engine/geometry";
13
+ import { nearestNodeSnap, polyFullyInside } from "@/core/engine/collision/grid-snapping";
14
+ import { checkFaceToFaceAttachment, wouldRemovalDisconnectSector } from "@/core/engine/validation/face-to-face";
15
+ import { blueprintLocalFromWorld } from "./utils";
16
+ import type { AnchorDots } from "./useAnchorGrid";
17
+ import type { PieceData } from "./usePieceState";
18
+ import { CONFIG } from "@/core/config/config";
19
+
20
+ interface DragState {
21
+ id: string;
22
+ dx: number;
23
+ dy: number;
24
+ tlx: number;
25
+ tly: number;
26
+ aabb: { width: number; height: number };
27
+ minx: number;
28
+ miny: number;
29
+ support: { x: number; y: number }[];
30
+ fromIcon?: boolean;
31
+ validSnap?: boolean;
32
+ prevValidSnap?: boolean;
33
+ raf?: number | null;
34
+ originalPos?: { x: number; y: number; sectorId: string | undefined } | undefined; // For prep mode: return to original position when invalid
35
+ pointerAnchor?: { x: number; y: number } | null;
36
+ pointerAnchorId?: string | null;
37
+ pointerAnchorSectorId?: string | undefined;
38
+ snapAnchor?: { x: number; y: number } | null;
39
+ snapAnchorId?: string | null;
40
+ snapAnchorSectorId?: string | undefined;
41
+ snapAnchorDist?: number | null;
42
+ overlaps?: boolean;
43
+ maxExceeded?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Return interface for useDragController hook
48
+ *
49
+ * Provides complete drag interaction functionality for both click and drag input modes.
50
+ * Handles piece spawning, dragging, collision detection, snapping, and placement validation.
51
+ */
52
+ export interface DragControllerHook {
53
+ // State
54
+ /** ID of currently dragging piece (null if none) */
55
+ draggingId: string | null;
56
+
57
+ /** True if current drag position is invalid (for visual feedback) */
58
+ dragInvalid: boolean;
59
+
60
+ /** ID of piece that is locked due to connectivity constraints (for visual feedback) */
61
+ lockedPieceId: string | null;
62
+
63
+ /** Ref to SVG element for coordinate transformation and pointer capture */
64
+ svgRef: React.RefObject<SVGSVGElement>;
65
+
66
+ // Handlers
67
+ /**
68
+ * Handler for piece pointer down events (both click and drag modes)
69
+ * @param e - Pointer event from SVG path or group element
70
+ * @param p - Piece data including position and blueprint information
71
+ */
72
+ onPiecePointerDown: (
73
+ e: React.PointerEvent<SVGPathElement | SVGGElement>,
74
+ p: { id: string; x: number; y: number; blueprintId: string; sectorId?: string }
75
+ ) => void;
76
+
77
+ /**
78
+ * Handler for blueprint icon pointer down events (spawning new pieces)
79
+ * @param e - Pointer event from blueprint icon
80
+ * @param bp - Blueprint definition for the new piece
81
+ * @param bpGeom - Geometry information for the blueprint icon
82
+ */
83
+ onBlueprintPointerDown: (
84
+ e: React.PointerEvent,
85
+ bp: Blueprint,
86
+ bpGeom: { bx: number; by: number; cx: number; cy: number }
87
+ ) => void;
88
+
89
+ /**
90
+ * Handler for pointer move events during drag operations
91
+ * @param e - Pointer event from SVG root element
92
+ */
93
+ onPointerMove: (e: React.PointerEvent<SVGSVGElement>) => void;
94
+
95
+ /** Handler for pointer up events (completing drag operations) */
96
+ onPointerUp: () => void;
97
+
98
+ /**
99
+ * Factory function for piece element refs (for direct DOM manipulation)
100
+ * @param id - Piece ID
101
+ * @returns Ref callback for SVG group element
102
+ */
103
+ setPieceRef: (id: string) => (el: SVGGElement | null) => void;
104
+
105
+ // Click controller integration
106
+ /**
107
+ * Setter for dragging ID (used by click controller for carry state)
108
+ * @param id - Piece ID or null to clear
109
+ */
110
+ setDraggingId: (id: string | null) => void;
111
+
112
+ // TODO: Remove this once click mode is extracted - temporary for compatibility
113
+ /** Internal drag state ref (temporary for click controller integration) */
114
+ dragRef: React.RefObject<DragState | null>;
115
+ }
116
+
117
+ /**
118
+ * Hook for managing drag interactions with RAF optimization and collision detection
119
+ *
120
+ * This is the most complex hook in the system, handling all aspects of piece interaction
121
+ * for both click and drag input modes. It manages piece spawning, dragging, collision
122
+ * detection, grid snapping, placement validation, and visual feedback.
123
+ *
124
+ * ## Key Features
125
+ * - **Dual input mode support**: Handles both click and drag interaction patterns
126
+ * - **RAF-optimized rendering**: Uses requestAnimationFrame for smooth drag performance
127
+ * - **Collision detection**: Real-time overlap checking with existing pieces
128
+ * - **Grid snapping**: Automatic snapping to anchor points with validation
129
+ * - **Placement validation**: Comprehensive validation for workspace/silhouette modes
130
+ * - **Visual feedback**: Real-time visual indicators for valid/invalid placement
131
+ *
132
+ * ## Input Mode Behavior
133
+ * - **Click mode**: First click starts "carry" state, second click places/cancels
134
+ * - **Drag mode**: Traditional drag-and-drop with immediate visual feedback
135
+ *
136
+ * ## Validation Rules
137
+ * - **Workspace mode**: Pieces must not overlap, any grid position valid
138
+ * - **Silhouette mode**: Pieces must be within silhouette mask, on valid anchors, no overlap
139
+ * - **Completed sectors**: No placement allowed, pieces deleted if dropped
140
+ *
141
+ * @param controller - BaseGameController for state management
142
+ * @param layout - Computed CircleLayout for sector geometry
143
+ * @param pieces - Current piece data from usePieceState
144
+ * @param pieceById - Lookup function for piece data
145
+ * @param anchorDots - Anchor grid data from useAnchorGrid
146
+ * @param placedSilBySector - Silhouette polygons for containment checking
147
+ * @param isSectorLocked - Function to check if sector is completed
148
+ * @param maybeCompleteSector - Callback to check sector completion after placement
149
+ * @param force - Force React re-render function
150
+ * @param clickController - Optional click controller integration
151
+ * @returns Hook interface with drag state and event handlers
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * const dragController = useDragController(
156
+ * controller, layout, pieces, pieceById, anchorDots,
157
+ * placedSilBySector, isSectorLocked, maybeCompleteSector, force
158
+ * );
159
+ *
160
+ * // Usage in JSX:
161
+ * <svg ref={dragController.svgRef} onPointerMove={dragController.onPointerMove}>
162
+ * <g onPointerDown={(e) => dragController.onPiecePointerDown(e, piece)}>
163
+ * <path />
164
+ * </g>
165
+ * </svg>
166
+ * ```
167
+ */
168
+ export function useDragController(
169
+ controller: BaseGameController,
170
+ layout: CircleLayout,
171
+ pieces: PieceData[],
172
+ pieceById: (id: string | null) => PieceData | null,
173
+ anchorDots: AnchorDots[],
174
+ placedSilBySector: Map<string, any[]>,
175
+ isSectorLocked: (id: string) => boolean,
176
+ maybeCompleteSector: (secId: string) => boolean,
177
+ force: () => void,
178
+ // Click controller integration
179
+ clickController?: {
180
+ setSelectedPiece: (id: string | null) => void;
181
+ setPendingBp: (id: string | null) => void;
182
+ },
183
+ // Interaction tracking
184
+ tracker?: {
185
+ recordPickup: (
186
+ pieceId: string,
187
+ blueprintId: string,
188
+ blueprintType: 'primitive' | 'composite',
189
+ source: 'blueprint' | 'sector',
190
+ position: { x: number; y: number },
191
+ vertices: number[][][],
192
+ sectorId?: string
193
+ ) => void;
194
+ recordPlacedown: (
195
+ outcome: 'placed' | 'deleted',
196
+ sectorId?: string,
197
+ position?: { x: number; y: number },
198
+ vertices?: number[][][],
199
+ anchorId?: string,
200
+ wasValid?: boolean,
201
+ wasOverlapping?: boolean,
202
+ completedSector?: boolean
203
+ ) => void;
204
+ recordMouseMove?: (anchorX: number, anchorY: number, sectorId?: string) => void;
205
+ recordClickEvent?: (
206
+ location: { x: number; y: number },
207
+ clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
208
+ metadata?: any
209
+ ) => void;
210
+ } | null
211
+ ): DragControllerHook {
212
+ const cfg = controller.state.cfg;
213
+ const clickMode = cfg.input === "click";
214
+
215
+ // Core drag state
216
+ const svgRef = React.useRef<SVGSVGElement | null>(null);
217
+ const dragRef = React.useRef<DragState | null>(null);
218
+ const [draggingId, setDraggingId] = React.useState<string | null>(null);
219
+ const elMap = React.useRef(new Map<string, SVGGElement>());
220
+
221
+ // Visual feedback for connectivity-locked pieces
222
+ const [lockedPieceId, setLockedPieceId] = React.useState<string | null>(null);
223
+
224
+ // Ensure dragRef is cleared when draggingId becomes null (for click controller integration)
225
+ React.useEffect(() => {
226
+ if (draggingId === null) {
227
+ dragRef.current = null;
228
+ }
229
+ }, [draggingId]);
230
+
231
+ const setPieceRef = (id: string) => (el: SVGGElement | null) => {
232
+ const m = elMap.current;
233
+ if (el) m.set(id, el); else m.delete(id);
234
+ };
235
+
236
+ const svgPoint = (clientX: number, clientY: number) => {
237
+ const svg = svgRef.current!;
238
+ const pt = svg.createSVGPoint();
239
+ pt.x = clientX; pt.y = clientY;
240
+ const ctm = svg.getScreenCTM();
241
+ if (!ctm) return { x: 0, y: 0 };
242
+ const sp = pt.matrixTransform(ctm.inverse());
243
+ return { x: sp.x, y: sp.y };
244
+ };
245
+
246
+ const scheduleFrame = () => {
247
+ if (!dragRef.current) return;
248
+ const d = dragRef.current;
249
+ if (d.raf != null) return;
250
+ d.raf = requestAnimationFrame(() => {
251
+ d.raf = null;
252
+ const g = elMap.current.get(d.id);
253
+ if (g) g.setAttribute("transform", `translate(${d.tlx - d.minx}, ${d.tly - d.miny})`);
254
+ });
255
+ };
256
+
257
+ const sectorIdAt = (x: number, y: number) => sectorAtPoint(x, y, layout, cfg.target);
258
+ const GRID_STEP = CONFIG.layout.grid.stepPx;
259
+
260
+ const anchorIdFromNode = (node: { x: number; y: number } | null | undefined): string | null => {
261
+ if (!node) return null;
262
+ const ax = Math.round(node.x / GRID_STEP);
263
+ const ay = Math.round(node.y / GRID_STEP);
264
+ return `${ax},${ay}`;
265
+ };
266
+
267
+ const anchorNodeFromPoint = (point: { x: number; y: number }) => ({
268
+ x: Math.round(point.x / GRID_STEP) * GRID_STEP,
269
+ y: Math.round(point.y / GRID_STEP) * GRID_STEP
270
+ });
271
+
272
+ const updatePointerAnchor = (point: { x: number; y: number }, sectorId: string | undefined) => {
273
+ if (!dragRef.current) return;
274
+ const d = dragRef.current;
275
+ const anchorNode = anchorNodeFromPoint(point);
276
+ const anchorId = anchorIdFromNode(anchorNode);
277
+
278
+ if (tracker?.recordMouseMove && (anchorId !== d.pointerAnchorId || sectorId !== d.pointerAnchorSectorId)) {
279
+ tracker.recordMouseMove(anchorNode.x, anchorNode.y, sectorId);
280
+ }
281
+
282
+ d.pointerAnchor = anchorNode;
283
+ d.pointerAnchorId = anchorId;
284
+ d.pointerAnchorSectorId = sectorId;
285
+ };
286
+
287
+ const updateSnapAnchor = (
288
+ node: { x: number; y: number } | null,
289
+ sectorId: string | undefined,
290
+ dist?: number
291
+ ) => {
292
+ if (!dragRef.current) return;
293
+ const d = dragRef.current;
294
+ d.snapAnchor = node;
295
+ d.snapAnchorId = anchorIdFromNode(node);
296
+ d.snapAnchorSectorId = sectorId;
297
+ d.snapAnchorDist = node && dist !== undefined ? dist : null;
298
+ };
299
+
300
+ const emitClickEvent = (
301
+ location: { x: number; y: number },
302
+ clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
303
+ metadata?: any
304
+ ) => {
305
+ if (!tracker?.recordClickEvent) return;
306
+ tracker.recordClickEvent(location, clickType, metadata);
307
+ };
308
+
309
+ // Helper: Compute piece vertices in world space
310
+ const getPieceVertices = (pieceId: string): number[][][] => {
311
+ const piece = controller.findPiece(pieceId);
312
+ if (!piece) return [];
313
+ const bp = controller.getBlueprint(piece.blueprintId);
314
+ if (!bp) return [];
315
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
316
+ const polys = piecePolysAt(bp, bb, piece.pos);
317
+ // Convert from {x,y}[][] to number[][][]
318
+ return polys.map(ring => ring.map(pt => [pt.x, pt.y]));
319
+ };
320
+
321
+ const onPiecePointerDown = (
322
+ e: React.PointerEvent<SVGPathElement | SVGGElement>,
323
+ p: { id: string; x: number; y: number; blueprintId: string; sectorId?: string }
324
+ ) => {
325
+ if (p.sectorId && isSectorLocked(p.sectorId)) return;
326
+ if (clickMode && draggingId) return; // ignore if already carrying
327
+
328
+ // Check connectivity in prep mode - prevent removal if it would disconnect remaining pieces
329
+ if (cfg.mode === "prep" && p.sectorId) {
330
+ const allPiecesInSector = controller.getPiecesInSector(p.sectorId);
331
+ const pieceToRemove = controller.findPiece(p.id);
332
+
333
+ if (pieceToRemove && wouldRemovalDisconnectSector(
334
+ pieceToRemove,
335
+ allPiecesInSector,
336
+ (id) => controller.getBlueprint(id),
337
+ (kind) => controller.getPrimitive(kind)
338
+ )) {
339
+ // Flash red briefly to indicate piece cannot be removed
340
+ setLockedPieceId(p.id);
341
+ setTimeout(() => setLockedPieceId(null), 500);
342
+ return; // Prevent pickup
343
+ }
344
+ }
345
+
346
+ if (clickMode) {
347
+ // Start a "carry" in click mode: make the piece follow the cursor
348
+ e.stopPropagation();
349
+ clickController?.setSelectedPiece(p.id);
350
+ clickController?.setPendingBp(null);
351
+
352
+ const bp = controller.getBlueprint(p.blueprintId)!;
353
+ const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
354
+ const support = computeSupportOffsets(bp, bb);
355
+ const { x, y } = svgPoint(e.clientX, e.clientY);
356
+
357
+ const pointerAnchorNode = anchorNodeFromPoint({ x, y });
358
+ const pointerSector = p.sectorId;
359
+ if (tracker?.recordMouseMove) {
360
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
361
+ }
362
+
363
+ // Record pickup event
364
+ if (tracker) {
365
+ const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
366
+ tracker.recordPickup(
367
+ p.id,
368
+ p.blueprintId,
369
+ blueprintType,
370
+ 'sector',
371
+ { x: p.x, y: p.y },
372
+ getPieceVertices(p.id),
373
+ p.sectorId
374
+ );
375
+ }
376
+
377
+ dragRef.current = {
378
+ id: p.id,
379
+ dx: x - p.x, dy: y - p.y,
380
+ tlx: p.x, tly: p.y,
381
+ aabb: { width: bb.width, height: bb.height },
382
+ minx: bb.min.x, miny: bb.min.y,
383
+ support,
384
+ fromIcon: false,
385
+ validSnap: true,
386
+ prevValidSnap: true,
387
+ raf: null,
388
+ originalPos: { x: p.x, y: p.y, sectorId: p.sectorId },
389
+ pointerAnchor: pointerAnchorNode,
390
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
391
+ pointerAnchorSectorId: pointerSector,
392
+ snapAnchor: null,
393
+ snapAnchorId: null,
394
+ snapAnchorSectorId: undefined,
395
+ snapAnchorDist: null,
396
+ overlaps: false,
397
+ maxExceeded: false,
398
+ };
399
+ updateSnapAnchor(null, p.sectorId);
400
+ setDraggingId(p.id);
401
+ force();
402
+ return;
403
+ }
404
+
405
+ // DRAG path
406
+ svgRef.current?.setPointerCapture(e.pointerId);
407
+ const bp = controller.getBlueprint(p.blueprintId)!;
408
+ const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
409
+ const support = computeSupportOffsets(bp, bb);
410
+ const { x, y } = svgPoint(e.clientX, e.clientY);
411
+
412
+ const pointerAnchorNode = anchorNodeFromPoint({ x, y });
413
+ const pointerSector = p.sectorId;
414
+ if (tracker?.recordMouseMove) {
415
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
416
+ }
417
+
418
+ // Record pickup event
419
+ if (tracker) {
420
+ const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
421
+ tracker.recordPickup(
422
+ p.id,
423
+ p.blueprintId,
424
+ blueprintType,
425
+ 'sector',
426
+ { x: p.x, y: p.y },
427
+ getPieceVertices(p.id),
428
+ p.sectorId
429
+ );
430
+ }
431
+
432
+ dragRef.current = {
433
+ id: p.id,
434
+ dx: x - p.x, dy: y - p.y,
435
+ tlx: p.x, tly: p.y,
436
+ aabb: { width: bb.width, height: bb.height },
437
+ minx: bb.min.x, miny: bb.min.y,
438
+ support,
439
+ fromIcon: false,
440
+ validSnap: true,
441
+ prevValidSnap: true,
442
+ raf: null,
443
+ originalPos: { x: p.x, y: p.y, sectorId: p.sectorId },
444
+ pointerAnchor: pointerAnchorNode,
445
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
446
+ pointerAnchorSectorId: pointerSector,
447
+ snapAnchor: null,
448
+ snapAnchorId: null,
449
+ snapAnchorSectorId: undefined,
450
+ snapAnchorDist: null,
451
+ overlaps: false,
452
+ maxExceeded: false,
453
+ };
454
+ updateSnapAnchor(null, p.sectorId);
455
+ setDraggingId(p.id);
456
+ force();
457
+ };
458
+
459
+ const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
460
+ const { x, y } = svgPoint(e.clientX, e.clientY);
461
+
462
+ // Always log pointer movement for mouse tracking, even before pickup
463
+ const pointerAnchorNode = anchorNodeFromPoint({ x, y });
464
+ const pointerR = Math.hypot(x - layout.cx, y - layout.cy);
465
+ const pointerSectorId = pointerR < layout.innerR ? undefined : sectorIdAt(x, y) ?? undefined;
466
+ if (!dragRef.current) {
467
+ if (tracker?.recordMouseMove) {
468
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSectorId);
469
+ }
470
+ return;
471
+ }
472
+
473
+ const d = dragRef.current;
474
+ const tlx = x - d.dx, tly = y - d.dy;
475
+
476
+ updatePointerAnchor({ x, y }, pointerSectorId);
477
+
478
+ const clamped = clampTopLeftBySupport(
479
+ tlx, tly,
480
+ { aabb: d.aabb, support: d.support },
481
+ layout,
482
+ controller.state.cfg.target,
483
+ pointerR < layout.innerR
484
+ );
485
+
486
+ let tl = clamped;
487
+ // Snap the *piece itself* to nearest nodes in the active sector (or inner ring),
488
+ // and compute validity (silhouette: within snapRadius; workspace: always valid).
489
+ const centerX = tl.x + d.aabb.width / 2;
490
+ const centerY = tl.y + d.aabb.height / 2;
491
+ const rFromCenter = Math.hypot(centerX - layout.cx, centerY - layout.cy);
492
+ const inInnerRing = rFromCenter < layout.innerR;
493
+ const secId = inInnerRing ? null : sectorIdAt(centerX, centerY);
494
+ let snapNode: { x: number; y: number } | null = null;
495
+ let snapDist: number | null = null;
496
+ let snapSectorId: string | undefined = secId ?? undefined;
497
+ let overlapsDetected = false;
498
+ let maxExceededDetected = false;
499
+
500
+ // Check for inner ring snapping first
501
+ if (inInnerRing) {
502
+ const innerEntry = anchorDots.find(a => a.sectorId === "inner-ring");
503
+ if (innerEntry && innerEntry.valid.length) {
504
+ const piece = pieceById(d.id);
505
+ if (piece) {
506
+ const bp = controller.getBlueprint(piece.blueprintId)!;
507
+ const snap = nearestNodeSnap(tl, bp, controller.getPrimitive, innerEntry.valid);
508
+ tl = snap.tl;
509
+ snapNode = snap.node;
510
+ snapDist = snap.dist;
511
+ snapSectorId = undefined;
512
+ }
513
+ }
514
+ // Inner ring shows RED (invalid) to indicate piece will be deleted
515
+ d.validSnap = false;
516
+ } else if (secId) {
517
+ const isCompletedSector = isSectorLocked(secId);
518
+ const entry = anchorDots.find(a => a.sectorId === secId);
519
+ const allNodes =
520
+ (cfg.target === "workspace" && cfg.mode !== "prep")
521
+ ? (entry?.valid ?? []) // Construction workspace: only valid nodes
522
+ : [ ...(entry?.valid ?? []), ...(entry?.invalid ?? []) ]; // Silhouette + Prep: all nodes
523
+
524
+ // Snap to anchors even in completed sectors
525
+ if (allNodes.length) {
526
+ const piece = pieceById(d.id);
527
+ if (piece) {
528
+ const bp = controller.getBlueprint(piece.blueprintId)!;
529
+ const snap = nearestNodeSnap(
530
+ tl,
531
+ bp,
532
+ controller.getPrimitive,
533
+ allNodes
534
+ );
535
+ tl = snap.tl;
536
+ snapNode = snap.node;
537
+ snapDist = snap.dist;
538
+ snapSectorId = secId;
539
+
540
+ // Completed sectors: always show red (invalid)
541
+ if (isCompletedSector) {
542
+ d.validSnap = false;
543
+ } else {
544
+ // Normal validation for incomplete sectors
545
+ const radius = controller.state.cfg.snapRadiusPx;
546
+ // Gather world polys for the carried piece at the *snapped* TL
547
+ const carried = pieceById(d.id);
548
+ let overlapsOk = true;
549
+ if (carried) {
550
+ const bpCar = controller.getBlueprint(carried.blueprintId)!;
551
+ const bbCar = boundsOfBlueprint(
552
+ bpCar,
553
+ controller.getPrimitive
554
+ );
555
+ const carriedPolys = piecePolysAt(bpCar, bbCar, tl);
556
+
557
+ // only compare against pieces in the same sector we're currently over
558
+ const sameSector = pieces.filter(
559
+ pp =>
560
+ pp.id !== d.id &&
561
+ sectorIdAt(
562
+ pp.x +
563
+ boundsOfBlueprint(
564
+ controller.getBlueprint(pp.blueprintId)!,
565
+ controller.getPrimitive
566
+ ).width / 2,
567
+ pp.y +
568
+ boundsOfBlueprint(
569
+ controller.getBlueprint(pp.blueprintId)!,
570
+ controller.getPrimitive
571
+ ).height / 2,
572
+ ) === secId
573
+ );
574
+
575
+ for (const other of sameSector) {
576
+ const bpO = controller.getBlueprint(other.blueprintId)!;
577
+ const bbO = boundsOfBlueprint(
578
+ bpO,
579
+ controller.getPrimitive
580
+ );
581
+ const otherPolys = piecePolysAt(bpO, bbO, { x: other.x, y: other.y });
582
+ if (polysOverlap(carriedPolys, otherPolys)) {
583
+ overlapsOk = false;
584
+ break;
585
+ }
586
+ }
587
+
588
+ // workspace: validity depends on mode
589
+ if (cfg.target === "workspace") {
590
+ if (cfg.mode === "prep") {
591
+ // Prep mode: ALWAYS allow movement (just show visual feedback)
592
+ // Face-to-face validation only affects visual feedback, not movement
593
+ const allPiecesInSector = controller.getPiecesInSector(secId);
594
+ const otherPiecesInSector = allPiecesInSector.filter(piece => piece.id !== d.id);
595
+ const isFirstPiece = otherPiecesInSector.length === 0;
596
+ const storedPiece = controller.findPiece(d.id);
597
+ const currentSectorId = storedPiece?.sectorId;
598
+ const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
599
+ const enforceMaxPieces = maxPieces > 0;
600
+ const effectiveCount = allPiecesInSector.length - (currentSectorId === secId ? 1 : 0);
601
+ const wouldExceedMax = enforceMaxPieces && (effectiveCount + 1 > maxPieces);
602
+
603
+ if (isFirstPiece) {
604
+ // First piece: visual feedback based on overlaps and max-piece limit
605
+ d.validSnap = overlapsOk && !wouldExceedMax;
606
+ maxExceededDetected = wouldExceedMax;
607
+ } else if (storedPiece) {
608
+ // Subsequent pieces: visual feedback based on face-to-face attachment
609
+ const pieceAtDragPosition = {
610
+ ...storedPiece,
611
+ pos: { x: tl.x, y: tl.y }
612
+ };
613
+
614
+ const attachmentResult = checkFaceToFaceAttachment(
615
+ pieceAtDragPosition,
616
+ otherPiecesInSector,
617
+ (id) => controller.getBlueprint(id),
618
+ (kind) => controller.getPrimitive(kind)
619
+ );
620
+
621
+ d.validSnap = overlapsOk && attachmentResult.isAttached && !wouldExceedMax;
622
+ maxExceededDetected = wouldExceedMax;
623
+ } else {
624
+ d.validSnap = overlapsOk && !wouldExceedMax;
625
+ maxExceededDetected = wouldExceedMax;
626
+ }
627
+
628
+ // IMPORTANT: In prep mode, always allow movement regardless of validSnap
629
+ // validSnap is only for visual feedback (red/blue coloring)
630
+ } else {
631
+ // Construction mode: original behavior (validSnap blocks movement)
632
+ d.validSnap = overlapsOk;
633
+ maxExceededDetected = false;
634
+ }
635
+ overlapsDetected = !overlapsOk;
636
+ } else {
637
+ // silhouette: node must be valid AND fully inside AND !overlap
638
+ const key = (p: { x: number; y: number }) => `${p.x},${p.y}`;
639
+ const validSet = new Set((entry?.valid ?? []).map(key));
640
+ const nodeOk =
641
+ !!snap.node && validSet.has(key(snap.node)) && snap.dist <= (radius ?? 0);
642
+ let insideOk = false;
643
+ if (nodeOk) {
644
+ const silPolys = placedSilBySector.get(secId) ?? [];
645
+ insideOk = polyFullyInside(carriedPolys, silPolys);
646
+ }
647
+ d.validSnap = nodeOk && insideOk && overlapsOk;
648
+ overlapsDetected = !overlapsOk;
649
+ }
650
+ }
651
+ }
652
+ }
653
+ }
654
+ } else {
655
+ // not over any sector band
656
+ d.validSnap = (controller.state.cfg.target === "workspace");
657
+ snapNode = null;
658
+ snapDist = null;
659
+ snapSectorId = undefined;
660
+ maxExceededDetected = false;
661
+ }
662
+ d.overlaps = overlapsDetected;
663
+ d.maxExceeded = maxExceededDetected;
664
+ updateSnapAnchor(snapNode, snapSectorId, snapDist ?? undefined);
665
+ d.tlx = tl.x; d.tly = tl.y;
666
+
667
+ // If the validity state changed, force a React render so fill/stroke update immediately.
668
+ if (d.prevValidSnap !== d.validSnap) {
669
+ d.prevValidSnap = d.validSnap ?? false;
670
+ force();
671
+ }
672
+
673
+ scheduleFrame();
674
+ };
675
+
676
+ const onPointerUp = () => {
677
+ if (!dragRef.current) return;
678
+ if (clickMode) return;
679
+
680
+ const d = dragRef.current;
681
+ // If the candidate is invalid for any reason (overlap/outside/bad node), handle based on mode
682
+ if (d.validSnap === false) {
683
+ const piece = controller.findPiece(d.id);
684
+ const cfg = controller.state.cfg;
685
+ const centerX = d.tlx + d.aabb.width / 2;
686
+ const centerY = d.tly + d.aabb.height / 2;
687
+ const attemptedSector = sectorIdAt(centerX, centerY);
688
+ let invalidReason: 'overlapping' | 'outside_bounds' | 'no_valid_anchor' | 'sector_complete' = 'outside_bounds';
689
+ if (d.maxExceeded) {
690
+ invalidReason = 'no_valid_anchor';
691
+ } else if (d.overlaps) {
692
+ invalidReason = 'overlapping';
693
+ } else if (attemptedSector) {
694
+ invalidReason = 'no_valid_anchor';
695
+ }
696
+ emitClickEvent(
697
+ { x: centerX, y: centerY },
698
+ 'invalid_placement',
699
+ { invalidPlacement: { reason: invalidReason, attemptedSectorId: attemptedSector ?? undefined } }
700
+ );
701
+
702
+ // In prep mode: return to original position (if exists) or delete (if from icon)
703
+ if (cfg.mode === "prep" && d.originalPos) {
704
+ // Return piece to its original position
705
+ controller.move(d.id, { x: d.originalPos.x, y: d.originalPos.y });
706
+
707
+ // Record placedown as "placed" back in original position
708
+ if (tracker) {
709
+ tracker.recordPlacedown(
710
+ 'placed',
711
+ d.originalPos.sectorId,
712
+ { x: d.originalPos.x, y: d.originalPos.y },
713
+ getPieceVertices(d.id),
714
+ d.snapAnchorId ?? undefined,
715
+ false,
716
+ d.overlaps ?? false
717
+ );
718
+ }
719
+ } else {
720
+ // Construction mode or fromIcon in prep mode: delete piece
721
+ const removedFromSector = piece?.sectorId;
722
+ controller.remove(d.id);
723
+ // Re-check completion for the sector the piece was removed from
724
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
725
+
726
+ // Record placedown as "deleted"
727
+ if (tracker) {
728
+ tracker.recordPlacedown(
729
+ 'deleted',
730
+ removedFromSector,
731
+ undefined,
732
+ undefined,
733
+ d.snapAnchorId ?? undefined,
734
+ false,
735
+ d.overlaps ?? false
736
+ );
737
+ }
738
+ }
739
+
740
+ dragRef.current = null;
741
+ setDraggingId(null);
742
+ force();
743
+ return;
744
+ }
745
+
746
+ dragRef.current = null;
747
+ if (d.raf) { cancelAnimationFrame(d.raf); d.raf = null; }
748
+
749
+ const commitTL = { x: d.tlx, y: d.tly };
750
+ const centerX = commitTL.x + d.aabb.width / 2;
751
+ const centerY = commitTL.y + d.aabb.height / 2;
752
+
753
+ // delete if inside the inner hole
754
+ const rFromCenter = Math.hypot(centerX - layout.cx, centerY - layout.cy);
755
+ if (rFromCenter < layout.innerR) {
756
+ const piece = controller.findPiece(d.id);
757
+ const removedFromSector = piece?.sectorId;
758
+ controller.remove(d.id);
759
+ // Re-check completion for the sector the piece was removed from
760
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
761
+
762
+ // Record placedown as "deleted"
763
+ if (tracker) {
764
+ tracker.recordPlacedown(
765
+ 'deleted',
766
+ removedFromSector,
767
+ undefined,
768
+ undefined,
769
+ d.snapAnchorId ?? undefined,
770
+ false,
771
+ d.overlaps ?? false
772
+ );
773
+ }
774
+
775
+ setDraggingId(null);
776
+ force();
777
+ return;
778
+ }
779
+
780
+ const secId = sectorIdAt(centerX, centerY);
781
+
782
+ // Completed sector: no-op (do nothing, same as clicking incomplete sector in silhouette mode)
783
+ if (secId && isSectorLocked(secId)) {
784
+ emitClickEvent(
785
+ { x: centerX, y: centerY },
786
+ 'sector_complete_attempt',
787
+ { invalidPlacement: { reason: 'sector_complete', attemptedSectorId: secId } }
788
+ );
789
+ // Return piece to original position if it exists, otherwise delete (from icon spawn)
790
+ if (d.originalPos) {
791
+ controller.move(d.id, { x: d.originalPos.x, y: d.originalPos.y });
792
+ if (d.originalPos.sectorId) {
793
+ controller.drop(d.id, d.originalPos.sectorId);
794
+ }
795
+
796
+ // Record placedown as "placed" back in original position
797
+ if (tracker) {
798
+ tracker.recordPlacedown(
799
+ 'placed',
800
+ d.originalPos.sectorId,
801
+ { x: d.originalPos.x, y: d.originalPos.y },
802
+ getPieceVertices(d.id),
803
+ d.snapAnchorId ?? undefined,
804
+ false,
805
+ d.overlaps ?? false
806
+ );
807
+ }
808
+ } else {
809
+ // Piece was spawned from icon and dropped in completed sector → delete
810
+ controller.remove(d.id);
811
+
812
+ // Record placedown as "deleted"
813
+ if (tracker) {
814
+ tracker.recordPlacedown(
815
+ 'deleted',
816
+ undefined,
817
+ undefined,
818
+ undefined,
819
+ d.snapAnchorId ?? undefined,
820
+ false,
821
+ d.overlaps ?? false
822
+ );
823
+ }
824
+ }
825
+ setDraggingId(null);
826
+ force();
827
+ return;
828
+ }
829
+
830
+ controller.move(d.id, commitTL);
831
+ controller.drop(d.id, secId);
832
+
833
+ const justCompleted = secId ? maybeCompleteSector(secId) : false;
834
+
835
+ // Record placedown as "placed" successfully
836
+ const wasValid = d.validSnap === undefined ? true : d.validSnap;
837
+ if (tracker) {
838
+ tracker.recordPlacedown(
839
+ 'placed',
840
+ secId,
841
+ commitTL,
842
+ getPieceVertices(d.id),
843
+ d.snapAnchorId ?? undefined,
844
+ wasValid,
845
+ d.overlaps ?? false,
846
+ justCompleted
847
+ );
848
+ }
849
+
850
+ setDraggingId(null);
851
+
852
+ // auto-revert AFTER completing a drag spawn from the center
853
+ if (d.fromIcon && controller.state.blueprintView === "primitives") {
854
+ controller.switchBlueprintView();
855
+ }
856
+ force();
857
+ };
858
+
859
+ const onBlueprintPointerDown = (
860
+ e: React.PointerEvent,
861
+ bp: Blueprint,
862
+ bpGeom: { bx: number; by: number; cx: number; cy: number }
863
+ ) => {
864
+ if (clickMode && draggingId) return;
865
+ e.stopPropagation();
866
+
867
+ const { x: px, y: py } = svgPoint(e.clientX, e.clientY);
868
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
869
+ const support = computeSupportOffsets(bp, bb);
870
+
871
+ if (clickMode) {
872
+ // Click mode: spawn then enter carry state
873
+
874
+ // Compute the local point of the blueprint glyph that was clicked
875
+ const q = blueprintLocalFromWorld(px, py, bpGeom);
876
+ // Compute TL so that the clicked point stays under the cursor
877
+ const tl0 = { x: px - (q.x - bb.min.x), y: py - (q.y - bb.min.y) };
878
+ const pointerR = Math.hypot(px - layout.cx, py - layout.cy);
879
+ const allowInside = pointerR < layout.innerR;
880
+ const clamped = clampTopLeftBySupport(
881
+ tl0.x, tl0.y,
882
+ { aabb: bb, support },
883
+ layout,
884
+ controller.state.cfg.target,
885
+ allowInside
886
+ );
887
+ const pieceId = controller.spawnFromBlueprint(bp, clamped);
888
+ const pointerAnchorNode = anchorNodeFromPoint({ x: px, y: py });
889
+ const pointerSector = sectorIdAt(px, py) ?? undefined;
890
+ if (tracker?.recordMouseMove) {
891
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
892
+ }
893
+
894
+ // Record pickup event for blueprint spawn
895
+ if (tracker) {
896
+ const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
897
+ tracker.recordPickup(
898
+ pieceId,
899
+ bp.id,
900
+ blueprintType,
901
+ 'blueprint',
902
+ clamped,
903
+ getPieceVertices(pieceId),
904
+ undefined
905
+ );
906
+ }
907
+
908
+ const tl = clamped;
909
+ // If anchors mode, immediately snap initial TL to the nearest nodes in the active sector
910
+ let finalTl = tl;
911
+ let snapInfo: { node: { x: number; y: number } | null; dist: number } | null = null;
912
+ const centerX = tl.x + bb.width / 2;
913
+ const centerY = tl.y + bb.height / 2;
914
+ const secId = sectorIdAt(centerX, centerY);
915
+ if (secId) {
916
+ const entry = anchorDots.find(a => a.sectorId === secId);
917
+ const nodes =
918
+ controller.state.cfg.target === "workspace"
919
+ ? (entry?.valid ?? [])
920
+ : [ ...(entry?.valid ?? []), ...(entry?.invalid ?? []) ];
921
+ if (nodes.length) {
922
+ const snap = nearestNodeSnap(tl, bp, controller.getPrimitive, nodes);
923
+ finalTl = snap.tl;
924
+ snapInfo = { node: snap.node, dist: snap.dist };
925
+ }
926
+ }
927
+
928
+ controller.move(pieceId, finalTl);
929
+ dragRef.current = {
930
+ id: pieceId,
931
+ dx: px - finalTl.x,
932
+ dy: py - finalTl.y,
933
+ tlx: finalTl.x,
934
+ tly: finalTl.y,
935
+ aabb: bb,
936
+ minx: bb.min.x,
937
+ miny: bb.min.y,
938
+ support,
939
+ fromIcon: true,
940
+ validSnap: true,
941
+ prevValidSnap: true,
942
+ raf: null,
943
+ originalPos: undefined, // No original position for fromIcon drags
944
+ pointerAnchor: pointerAnchorNode,
945
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
946
+ pointerAnchorSectorId: pointerSector,
947
+ snapAnchor: null,
948
+ snapAnchorId: null,
949
+ snapAnchorSectorId: undefined,
950
+ snapAnchorDist: null,
951
+ overlaps: false,
952
+ maxExceeded: false,
953
+ };
954
+ updatePointerAnchor({ x: px, y: py }, pointerSector);
955
+ const snappingSecId = sectorIdAt(finalTl.x + bb.width / 2, finalTl.y + bb.height / 2) ?? undefined;
956
+ updateSnapAnchor(snapInfo?.node ?? null, snappingSecId, snapInfo?.dist);
957
+ setDraggingId(pieceId); // now onPointerMove will keep it in sync
958
+ force();
959
+ scheduleFrame();
960
+ return;
961
+ }
962
+
963
+ // Drag mode: spawn at pointer with immediate drag
964
+ svgRef.current?.setPointerCapture(e.pointerId);
965
+
966
+ // Compute the local point of the blueprint glyph that was clicked
967
+ const q = blueprintLocalFromWorld(px, py, bpGeom);
968
+ // Compute TL so that the clicked point stays under the cursor
969
+ const tl0 = { x: px - (q.x - bb.min.x), y: py - (q.y - bb.min.y) };
970
+ const pointerR = Math.hypot(px - layout.cx, py - layout.cy);
971
+ const allowInside = pointerR < layout.innerR;
972
+ const clamped = clampTopLeftBySupport(
973
+ tl0.x, tl0.y,
974
+ { aabb: bb, support },
975
+ layout,
976
+ controller.state.cfg.target,
977
+ allowInside
978
+ );
979
+ const pieceId = controller.spawnFromBlueprint(bp, clamped);
980
+ const pointerAnchorNode = anchorNodeFromPoint({ x: px, y: py });
981
+ const pointerSector = sectorIdAt(px, py) ?? undefined;
982
+ if (tracker?.recordMouseMove) {
983
+ tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
984
+ }
985
+
986
+ // Record pickup event for blueprint spawn
987
+ if (tracker) {
988
+ const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
989
+ tracker.recordPickup(
990
+ pieceId,
991
+ bp.id,
992
+ blueprintType,
993
+ 'blueprint',
994
+ clamped,
995
+ getPieceVertices(pieceId),
996
+ undefined
997
+ );
998
+ }
999
+
1000
+ dragRef.current = {
1001
+ id: pieceId,
1002
+ dx: px - clamped.x,
1003
+ dy: py - clamped.y,
1004
+ tlx: clamped.x,
1005
+ tly: clamped.y,
1006
+ aabb: bb,
1007
+ minx: bb.min.x,
1008
+ miny: bb.min.y,
1009
+ support,
1010
+ fromIcon: true,
1011
+ validSnap: true,
1012
+ prevValidSnap: true,
1013
+ raf: null,
1014
+ originalPos: undefined, // No original position for fromIcon drags
1015
+ pointerAnchor: pointerAnchorNode,
1016
+ pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
1017
+ pointerAnchorSectorId: pointerSector,
1018
+ snapAnchor: null,
1019
+ snapAnchorId: null,
1020
+ snapAnchorSectorId: undefined,
1021
+ snapAnchorDist: null,
1022
+ overlaps: false,
1023
+ };
1024
+ updatePointerAnchor({ x: px, y: py }, pointerSector);
1025
+ updateSnapAnchor(null, undefined);
1026
+ setDraggingId(pieceId);
1027
+ force();
1028
+ scheduleFrame();
1029
+ };
1030
+
1031
+ return {
1032
+ // State
1033
+ draggingId,
1034
+ dragInvalid: !!(dragRef.current && dragRef.current.validSnap === false),
1035
+ lockedPieceId,
1036
+ svgRef,
1037
+
1038
+ // Handlers
1039
+ onPiecePointerDown,
1040
+ onBlueprintPointerDown,
1041
+ onPointerMove,
1042
+ onPointerUp,
1043
+ setPieceRef,
1044
+
1045
+ // Click controller integration
1046
+ setDraggingId,
1047
+
1048
+ // TODO: Remove this once click mode is extracted - temporary for compatibility
1049
+ dragRef
1050
+ };
1051
+ }