jspsych-tangram 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/construct/index.browser.js +20431 -0
- package/dist/construct/index.browser.js.map +1 -0
- package/dist/construct/index.browser.min.js +42 -0
- package/dist/construct/index.browser.min.js.map +1 -0
- package/dist/construct/index.cjs +3720 -0
- package/dist/construct/index.cjs.map +1 -0
- package/dist/construct/index.d.ts +204 -0
- package/dist/construct/index.js +3718 -0
- package/dist/construct/index.js.map +1 -0
- package/dist/index.cjs +3920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +340 -0
- package/dist/index.js +3917 -0
- package/dist/index.js.map +1 -0
- package/dist/prep/index.browser.js +20455 -0
- package/dist/prep/index.browser.js.map +1 -0
- package/dist/prep/index.browser.min.js +42 -0
- package/dist/prep/index.browser.min.js.map +1 -0
- package/dist/prep/index.cjs +3744 -0
- package/dist/prep/index.cjs.map +1 -0
- package/dist/prep/index.d.ts +139 -0
- package/dist/prep/index.js +3742 -0
- package/dist/prep/index.js.map +1 -0
- package/package.json +77 -0
- package/src/core/components/README.md +249 -0
- package/src/core/components/board/BoardView.tsx +352 -0
- package/src/core/components/board/GameBoard.tsx +682 -0
- package/src/core/components/board/index.ts +70 -0
- package/src/core/components/board/useAnchorGrid.ts +110 -0
- package/src/core/components/board/useClickController.ts +436 -0
- package/src/core/components/board/useDragController.ts +1051 -0
- package/src/core/components/board/usePieceState.ts +178 -0
- package/src/core/components/board/utils.ts +76 -0
- package/src/core/components/index.ts +33 -0
- package/src/core/components/pieces/BlueprintRing.tsx +238 -0
- package/src/core/config/config.ts +85 -0
- package/src/core/domain/blueprints.ts +25 -0
- package/src/core/domain/layout.ts +159 -0
- package/src/core/domain/primitives.ts +159 -0
- package/src/core/domain/solve.ts +184 -0
- package/src/core/domain/types.ts +111 -0
- package/src/core/engine/collision/grid-snapping.ts +283 -0
- package/src/core/engine/collision/index.ts +4 -0
- package/src/core/engine/collision/sat-collision.ts +46 -0
- package/src/core/engine/collision/validation.ts +166 -0
- package/src/core/engine/geometry/bounds.ts +91 -0
- package/src/core/engine/geometry/collision.ts +64 -0
- package/src/core/engine/geometry/index.ts +19 -0
- package/src/core/engine/geometry/math.ts +101 -0
- package/src/core/engine/geometry/pieces.ts +290 -0
- package/src/core/engine/geometry/polygons.ts +43 -0
- package/src/core/engine/state/BaseGameController.ts +368 -0
- package/src/core/engine/validation/border-rendering.ts +318 -0
- package/src/core/engine/validation/complete.ts +102 -0
- package/src/core/engine/validation/face-to-face.ts +217 -0
- package/src/core/index.ts +3 -0
- package/src/core/io/InteractionTracker.ts +742 -0
- package/src/core/io/data-tracking.ts +271 -0
- package/src/core/io/json-to-tangram-spec.ts +110 -0
- package/src/core/io/quickstash.ts +141 -0
- package/src/core/io/stims.ts +110 -0
- package/src/core/types/index.ts +5 -0
- package/src/core/types/plugin-interfaces.ts +101 -0
- package/src/index.spec.ts +19 -0
- package/src/index.ts +2 -0
- package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
- package/src/plugins/tangram-construct/index.ts +156 -0
- package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
- package/src/plugins/tangram-prep/index.ts +122 -0
- package/tangram-construct.min.js +42 -0
- package/tangram-prep.min.js +42 -0
|
@@ -0,0 +1,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
|