jspsych-tangram 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. package/tangram-prep.min.js +42 -0
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "jspsych-tangram",
3
+ "version": "0.0.1",
4
+ "description": "Tangram tasks for jsPsych: prep and construct.",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "typings": "dist/index.d.ts",
9
+ "unpkg": "tangram-prep.min.js",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./prep": {
17
+ "types": "./dist/prep/index.d.ts",
18
+ "import": "./dist/prep/index.js",
19
+ "require": "./dist/prep/index.cjs"
20
+ },
21
+ "./construct": {
22
+ "types": "./dist/construct/index.d.ts",
23
+ "import": "./dist/construct/index.js",
24
+ "require": "./dist/construct/index.cjs"
25
+ }
26
+ },
27
+ "files": [
28
+ "src",
29
+ "dist",
30
+ "tangram-prep.min.js",
31
+ "tangram-construct.min.js"
32
+ ],
33
+ "source": "src/index.ts",
34
+ "scripts": {
35
+ "build": "rollup --config",
36
+ "postbuild": "cp dist/prep/index.browser.min.js tangram-prep.min.js && cp dist/construct/index.browser.min.js tangram-construct.min.js",
37
+ "build:watch": "npm run build -- --watch",
38
+ "dev": "vite",
39
+ "test": "jest",
40
+ "type-check": "tsc --noEmit",
41
+ "lint": "eslint src --ext .ts,.tsx",
42
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
43
+ "check-all": "npm run type-check && npm run lint"
44
+ },
45
+ "dependencies": {
46
+ "@citation-js/core": "^0.7.14",
47
+ "@citation-js/plugin-bibtex": "^0.7.14",
48
+ "@citation-js/plugin-cff": "^0.6.1",
49
+ "@citation-js/plugin-software-formats": "^0.6.1",
50
+ "jspsych": "^8.0.0",
51
+ "react": "^19.0.0",
52
+ "react-dom": "^19.0.0",
53
+ "uuid": "^11.1.0"
54
+ },
55
+ "devDependencies": {
56
+ "@jspsych/config": "^3.2.2",
57
+ "@jspsych/test-utils": "^1.0.0",
58
+ "@rollup/plugin-alias": "^5.1.1",
59
+ "@rollup/plugin-commonjs": "^25.0.7",
60
+ "@rollup/plugin-json": "^6.1.0",
61
+ "@rollup/plugin-node-resolve": "^15.2.3",
62
+ "@rollup/plugin-replace": "^6.0.2",
63
+ "@rollup/plugin-url": "^8.0.2",
64
+ "@types/react": "^18.0.0",
65
+ "@types/react-dom": "^18.0.0",
66
+ "@typescript-eslint/eslint-plugin": "^8.46.1",
67
+ "@typescript-eslint/parser": "^8.46.1",
68
+ "@vitejs/plugin-react": "^4.4.1",
69
+ "eslint": "^9.35.0",
70
+ "eslint-plugin-react": "^7.37.5",
71
+ "eslint-plugin-react-hooks": "^5.2.0",
72
+ "eslint-plugin-unused-imports": "^4.3.0",
73
+ "rollup-plugin-postcss": "^4.0.2",
74
+ "typescript": "^5.7.0",
75
+ "vite": "^6.3.5"
76
+ }
77
+ }
@@ -0,0 +1,249 @@
1
+ # Core Components - jsPsych Plugin Development
2
+
3
+ This directory contains the core reusable components for building tangram-based jsPsych plugins. All components follow dependency injection principles with no hardcoded defaults.
4
+
5
+ ## Main Components
6
+
7
+ ### GameBoard
8
+ **File**: `GameBoard.tsx` (180+ lines)
9
+ **Purpose**: Complete tangram game functionality for plugin reuse
10
+ **Features**:
11
+ - Dependency injection for all game content
12
+ - Support for click and drag interaction modes
13
+ - Circle and semicircle layout modes
14
+ - Comprehensive event tracking for research data
15
+ - Responsive sizing for different display contexts
16
+
17
+ ```typescript
18
+ import { GameBoard } from "@/core/components";
19
+
20
+ // In jsPsych plugin trial method:
21
+ root.render(React.createElement(GameBoard, {
22
+ sectors: convertedTangrams,
23
+ quickstash: convertedMacros,
24
+ primitives: PRIMITIVE_BLUEPRINTS,
25
+ layout: "semicircle",
26
+ target: "silhouette",
27
+ input: "drag",
28
+ onComplete: handleTrialComplete
29
+ }));
30
+ ```
31
+
32
+ ### BlueprintRing
33
+ **File**: `BlueprintRing.tsx` (140+ lines)
34
+ **Purpose**: Blueprint selection interface with center toggle badge
35
+ **Features**:
36
+ - Displays primitives and quickstash in circular arrangement
37
+ - Interactive center badge for switching collections
38
+ - Automatic geometry calculation based on content
39
+ - Pure presentation component (no internal state)
40
+
41
+ ```typescript
42
+ import { BlueprintRing } from "@/core/components";
43
+
44
+ <BlueprintRing
45
+ primitives={primitiveBlueprints}
46
+ quickstash={macroBlueprints}
47
+ currentView="primitives"
48
+ layout={computedLayout}
49
+ onBlueprintPointerDown={handleSelection}
50
+ onCenterBadgePointerDown={handleToggle}
51
+ />
52
+ ```
53
+
54
+ ## Hook System
55
+
56
+ ### Individual Hooks
57
+ **Directory**: `board/`
58
+ **Purpose**: Granular state management for advanced plugin development
59
+
60
+ - **usePieceState**: Piece data management and polygon computation
61
+ - **useAnchorGrid**: Grid validation and anchor point calculation
62
+ - **useDragController**: Complete drag interaction handling (447 lines)
63
+ - **useClickController**: Click-based interaction with selection state
64
+
65
+ ```typescript
66
+ import {
67
+ usePieceState,
68
+ useAnchorGrid,
69
+ useDragController,
70
+ useClickController
71
+ } from "@/core/components";
72
+
73
+ // Advanced usage - building custom board components
74
+ const pieceState = usePieceState(controller);
75
+ const anchorGrid = useAnchorGrid(controller, layout);
76
+ const dragController = useDragController(
77
+ controller, layout, pieces, /* ... callbacks */
78
+ );
79
+ ```
80
+
81
+ ## Architecture Principles
82
+
83
+ ### Dependency Injection
84
+ All components receive their data and behavior through props:
85
+ - **No hardcoded defaults** - everything must be provided
86
+ - **No global state** - all state managed through props/hooks
87
+ - **No singletons** - components can be instantiated multiple times
88
+
89
+ ### Plugin Integration
90
+ Components are designed specifically for jsPsych plugin development:
91
+ - **JSON interfaces** - Accept data in jsPsych-compatible formats
92
+ - **Event callbacks** - Provide structured data for plugin data collection
93
+ - **Flexible sizing** - Support different display contexts and screen sizes
94
+ - **Clean lifecycle** - Components can be unmounted/remounted cleanly
95
+
96
+ ### State Management
97
+ Uses modern React patterns with functional components:
98
+ - **Hook-based architecture** - All state logic in custom hooks
99
+ - **BaseGameController integration** - Consistent state management
100
+ - **Pure components** - Presentation separated from state logic
101
+ - **Predictable updates** - Clear data flow and update patterns
102
+
103
+ ## Usage Patterns
104
+
105
+ ### Basic Plugin Development
106
+ For most jsPsych plugins, use GameBoard directly:
107
+
108
+ ```typescript
109
+ import { GameBoard } from "@/core/components";
110
+ import { PRIMITIVE_BLUEPRINTS } from "@/core/domain/primitives";
111
+
112
+ class MyTangramPlugin implements JsPsychPlugin<Info> {
113
+ trial(display_element: HTMLElement, trial: TrialType<Info>) {
114
+ const root = createRoot(display_element);
115
+ root.render(React.createElement(GameBoard, {
116
+ sectors: trial.tangrams, // Convert from JSON
117
+ quickstash: trial.macros, // Convert from JSON
118
+ primitives: PRIMITIVE_BLUEPRINTS,
119
+ layout: trial.layout,
120
+ target: trial.target,
121
+ input: trial.input,
122
+ onComplete: (snapshot) => {
123
+ // Process trial data
124
+ this.jsPsych.finishTrial(snapshot);
125
+ }
126
+ }));
127
+ }
128
+ }
129
+ ```
130
+
131
+ ### Advanced Plugin Development
132
+ For custom functionality, use individual hooks:
133
+
134
+ ```typescript
135
+ import { usePieceState, useDragController } from "@/core/components";
136
+
137
+ function CustomGameInterface({ controller, layout }) {
138
+ const pieces = usePieceState(controller);
139
+ const dragState = useDragController(controller, layout, /* ... */);
140
+
141
+ // Build custom UI using hook state
142
+ return (
143
+ <div>
144
+ {/* Custom rendering using pieces and dragState */}
145
+ </div>
146
+ );
147
+ }
148
+ ```
149
+
150
+ ### Component Composition
151
+ Combine components for specialized interfaces:
152
+
153
+ ```typescript
154
+ import { BlueprintRing, GameBoard } from "@/core/components";
155
+
156
+ function PrepPluginInterface() {
157
+ return (
158
+ <div>
159
+ <GameBoard /* construction area */ />
160
+ <BlueprintRing /* macro creation */ />
161
+ {/* Additional prep-specific UI */}
162
+ </div>
163
+ );
164
+ }
165
+ ```
166
+
167
+ ## Data Flow
168
+
169
+ ### Plugin → GameBoard
170
+ 1. Plugin receives JSON parameters from jsPsych
171
+ 2. Plugin converts JSON to internal types (Sector[], Blueprint[])
172
+ 3. Plugin passes converted data to GameBoard via props
173
+ 4. GameBoard creates BaseGameController with injected data
174
+
175
+ ### GameBoard → Plugin
176
+ 1. User interactions trigger game events
177
+ 2. BaseGameController updates state and logs events
178
+ 3. GameBoard calls plugin callbacks (onComplete, onSectorComplete, etc.)
179
+ 4. Plugin processes events and calls jsPsych.finishTrial()
180
+
181
+ ### Internal Component Flow
182
+ 1. GameBoard creates BaseGameController and computes layout
183
+ 2. GameBoard renders Board component with computed state
184
+ 3. Board uses hooks for interaction handling (drag/click controllers)
185
+ 4. Hook events trigger BaseGameController state updates
186
+ 5. State changes cause React re-renders with updated visual state
187
+
188
+ ## Type Safety
189
+
190
+ All components are fully typed with TypeScript:
191
+ - **Strict interfaces** - All props and state are strongly typed
192
+ - **Generic support** - Components work with plugin-specific data types
193
+ - **JSDoc documentation** - Comprehensive inline documentation
194
+ - **Export types** - All interfaces available for plugin development
195
+
196
+ ```typescript
197
+ import type {
198
+ GameBoardProps,
199
+ GameBoardConfig,
200
+ BlueprintRingProps
201
+ } from "@/core/components";
202
+ ```
203
+
204
+ ## Performance Considerations
205
+
206
+ ### Optimized for Interactive Use
207
+ - **Efficient re-rendering** - Components use React.memo and useMemo appropriately
208
+ - **Event handling** - Pointer events optimized for both mouse and touch
209
+ - **Geometry caching** - Layout calculations cached to prevent redundant computation
210
+ - **Memory management** - No memory leaks in event handlers or timers
211
+
212
+ ### Scalable Architecture
213
+ - **Large piece counts** - Handles many primitive and macro pieces efficiently
214
+ - **Complex layouts** - Supports both simple and complex sector arrangements
215
+ - **Real-time updates** - Smooth interaction even with complex collision detection
216
+
217
+ ## Testing & Development
218
+
219
+ ### Development Environment
220
+ Use the provided test files for component development:
221
+ - `dev/plugin-test.html` - Test complete plugin functionality
222
+ - `dev/checkpoint-test.html` - Validate component architecture
223
+
224
+ ### Plugin Testing
225
+ Create jsPsych trials to test component integration:
226
+
227
+ ```typescript
228
+ const testTrial = {
229
+ type: YourTangramPlugin,
230
+ sectors: mockSectors,
231
+ quickstash: mockMacros,
232
+ layout: "circle",
233
+ target: "workspace",
234
+ input: "click"
235
+ };
236
+ ```
237
+
238
+ ---
239
+
240
+ ## Migration from Legacy Code
241
+
242
+ If migrating from the original monolithic Board.tsx:
243
+ - Replace Board usage with GameBoard
244
+ - Convert hardcoded data to props
245
+ - Replace RoundController with BaseGameController
246
+ - Update event handling to use callback props
247
+ - Test thoroughly with both click and drag modes
248
+
249
+ The components in this directory represent the completed Phase 3-4 refactoring that transformed 838 lines of monolithic code into a clean, reusable plugin architecture.
@@ -0,0 +1,352 @@
1
+ import React from "react";
2
+ import type { Poly, TanKind, Blueprint, PrimitiveBlueprint } from "@/core/domain/types";
3
+ import { wedgePath, type CircleLayout } from "@/core/domain/layout";
4
+ import { boundsOfBlueprint } from "@/core/engine/geometry";
5
+ import { CONFIG } from "@/core/config/config";
6
+ import { generateEdgeStrokePaths, shouldUseSelectiveBorders, shouldShowBorders, getHiddenEdgesForPolygon } from "@/core/engine/validation/border-rendering";
7
+
8
+ export type PieceView = { id: string; blueprintId: string; x: number; y: number; sectorId?: string };
9
+ export type AnchorDots = { sectorId: string; valid: {x:number;y:number}[]; invalid: {x:number;y:number}[] };
10
+
11
+ export type BoardViewProps = {
12
+ controller: any; // RoundController (kept loose to avoid circular deps here)
13
+ layout: CircleLayout;
14
+ viewBox: { w: number; h: number };
15
+ width?: number;
16
+ height?: number;
17
+
18
+ // derived visuals/geometry
19
+ badgeR: number;
20
+ badgeCenter: { x: number; y: number };
21
+ placedSilBySector: Map<string, Poly[]>;
22
+ anchorDots: AnchorDots[];
23
+ pieces: PieceView[];
24
+
25
+ // UI state flags
26
+ clickMode: boolean;
27
+ draggingId: string | null;
28
+ selectedPieceId: string | null;
29
+ dragInvalid: boolean;
30
+ lockedPieceId: string | null;
31
+
32
+ // refs + handlers wired by the container
33
+ svgRef: React.RefObject<SVGSVGElement>;
34
+ setPieceRef: (id: string) => (el: SVGGElement | null) => void;
35
+ onPiecePointerDown: (
36
+ e: React.PointerEvent<SVGPathElement | SVGGElement>,
37
+ p: PieceView
38
+ ) => void;
39
+ onBlueprintPointerDown: (
40
+ e: React.PointerEvent,
41
+ bp: Blueprint,
42
+ bpGeom: { bx: number; by: number; cx: number; cy: number }
43
+ ) => void;
44
+ onRootPointerDown: (e: React.PointerEvent<SVGSVGElement>) => void;
45
+ onPointerMove: (e: React.PointerEvent<SVGSVGElement>) => void;
46
+ onPointerUp: () => void;
47
+ onCenterBadgePointerDown: (e: React.PointerEvent) => void;
48
+ };
49
+
50
+ function pathD(poly: Poly) {
51
+ return `M ${poly.map((pt) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
52
+ }
53
+
54
+ export default function BoardView(props: BoardViewProps) {
55
+ const {
56
+ controller, layout, viewBox, width, height,
57
+ badgeR, badgeCenter,
58
+ placedSilBySector, anchorDots, pieces,
59
+ clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId,
60
+ svgRef, setPieceRef,
61
+ onPiecePointerDown, onBlueprintPointerDown, onRootPointerDown, onPointerMove, onPointerUp, onCenterBadgePointerDown,
62
+ } = props;
63
+
64
+ const VW = viewBox.w, VH = viewBox.h;
65
+
66
+ // blueprint ring geometry
67
+ const centerView = controller.state.blueprintView;
68
+ const bps: Blueprint[] =
69
+ centerView === "primitives"
70
+ ? (controller.state.primitives as PrimitiveBlueprint[])
71
+ : (controller.state.quickstash as Blueprint[]);
72
+
73
+ const QS_SLOTS = controller.state.cfg.maxQuickstashSlots;
74
+ const PRIM_SLOTS = controller.state.primitives.length;
75
+ const slotsForView = centerView === "primitives" ? Math.max(1, PRIM_SLOTS) : Math.max(1, QS_SLOTS);
76
+ const sweep = layout.mode === "circle" ? Math.PI * 2 : Math.PI;
77
+ const delta = sweep / slotsForView;
78
+
79
+ const start = layout.mode === "circle" ? -Math.PI / 2 : Math.PI;
80
+ const blueprintTheta = (i: number) => start + (i + 0.5) * delta;
81
+
82
+ // chord requirement (reuse same logic as container already used for radii)
83
+ const anchorsDiameterToPx = (anchorsDiag: number, gridPx: number = CONFIG.layout.grid.stepPx) =>
84
+ anchorsDiag * Math.SQRT2 * gridPx;
85
+ const reqAnchors = centerView === "primitives"
86
+ ? CONFIG.layout.constraints.primitiveDiamAnchors
87
+ : CONFIG.layout.constraints.quickstashDiamAnchors;
88
+ const D_slot = anchorsDiameterToPx(reqAnchors);
89
+ const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
90
+
91
+ // Minimum radius to avoid overlapping with badge
92
+ // Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
93
+ const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
94
+ const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
95
+
96
+ // Clamp to [R_min, ringMax]
97
+ const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
98
+
99
+ const renderBlueprintGlyph = (bp: Blueprint, bx: number, by: number) => {
100
+ const bb = boundsOfBlueprint(bp, (k: string) => controller.getPrimitive(k as TanKind)!);
101
+ const cx = bb.min.x + bb.width / 2;
102
+ const cy = bb.min.y + bb.height / 2;
103
+ const selected = false; // container highlights via pending state; optional
104
+
105
+ return (
106
+ <g
107
+ key={bp.id}
108
+ transform={`translate(${bx}, ${by}) scale(1) translate(${-cx}, ${-cy})`}
109
+ >
110
+ {("shape" in bp ? bp.shape : []).map((poly, idx) => (
111
+ <path
112
+ key={idx}
113
+ d={pathD(poly)}
114
+ fill={CONFIG.color.blueprint.fill}
115
+ opacity={CONFIG.opacity.blueprint}
116
+ stroke={selected ? CONFIG.color.blueprint.selectedStroke : "none"}
117
+ strokeWidth={selected ? 2 : 0}
118
+ pointerEvents="visiblePainted"
119
+ style={{ cursor: "pointer" }}
120
+ onPointerDown={(e) => onBlueprintPointerDown(e as any, bp, { bx, by, cx, cy })}
121
+ />
122
+ ))}
123
+ </g>
124
+ );
125
+ };
126
+
127
+ return (
128
+ <svg
129
+ ref={svgRef}
130
+ width={width}
131
+ height={height}
132
+ viewBox={`0 0 ${VW} ${VH}`}
133
+ preserveAspectRatio="xMidYMid meet"
134
+ onPointerMove={onPointerMove}
135
+ onPointerUp={onPointerUp}
136
+ onPointerDown={(e) => {
137
+ onRootPointerDown(e);
138
+ }}
139
+ style={{ background: "#fff", touchAction: "none", userSelect: "none" }}
140
+ >
141
+ {/* bands as wedges */}
142
+ {layout.sectors.map((s, i) => {
143
+ const done = !!controller.state.sectors[s.id].completedAt;
144
+ const baseSil = i % 2 ? CONFIG.color.bands.silhouette.fillOdd : CONFIG.color.bands.silhouette.fillEven;
145
+ const baseWork = i % 2 ? CONFIG.color.bands.workspace.fillOdd : CONFIG.color.bands.workspace.fillEven;
146
+ const sil = layout.bands.silhouette;
147
+ const work = layout.bands.workspace;
148
+ return (
149
+ <g key={`bands-${s.id}`}>
150
+ {controller.state.cfg.target === "workspace" ? (
151
+ <>
152
+ <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" />
153
+ <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" />
154
+ </>
155
+ ) : (
156
+ <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" />
157
+ )}
158
+ </g>
159
+ );
160
+ })}
161
+
162
+
163
+ {/* live pieces - MOVED ABOVE SILHOUETTES FOR TESTING */}
164
+ {pieces
165
+ .sort((a, b) => {
166
+ // Sort so that dragged piece is rendered last (on top)
167
+ if (draggingId === a.id) return 1;
168
+ if (draggingId === b.id) return -1;
169
+ return 0;
170
+ })
171
+ .map((p) => {
172
+ const bp = controller.getBlueprint(p.blueprintId)!;
173
+ const bb = boundsOfBlueprint(bp, (k: string) => controller.getPrimitive(k as TanKind)!);
174
+ const isDragging = draggingId === p.id;
175
+ const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
176
+ const isConnectivityLocked = lockedPieceId === p.id;
177
+ const isSelected = selectedPieceId === p.id;
178
+ const isCarriedInvalid = isDragging && dragInvalid;
179
+ const translateX = p.x - bb.min.x;
180
+ const translateY = p.y - bb.min.y;
181
+ return (
182
+ <g key={p.id} ref={setPieceRef(p.id)} transform={`translate(${translateX}, ${translateY})`} style={{ cursor: locked ? "default" : clickMode ? "pointer" : "grab" }} pointerEvents={clickMode && isDragging ? "none" : "auto"}>
183
+ {("shape" in bp ? bp.shape : []).map((poly: any, idx: number) => {
184
+ const showBorders = shouldShowBorders();
185
+ const useSelectiveBorders = shouldUseSelectiveBorders(p.blueprintId);
186
+ return (
187
+ <React.Fragment key={idx}>
188
+ {/* Fill path - always rendered with no borders */}
189
+ <path
190
+ d={pathD(poly)}
191
+ fill={isConnectivityLocked ? CONFIG.color.piece.invalidFill : (isCarriedInvalid ? CONFIG.color.piece.invalidFill : (isDragging ? CONFIG.color.piece.draggingFill : CONFIG.color.piece.validFill))}
192
+ 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)))}
193
+ stroke="none"
194
+ onPointerDown={(e) => onPiecePointerDown(e, p)}
195
+ />
196
+
197
+ {/* Border paths - only rendered if borders are enabled */}
198
+ {showBorders && (
199
+ useSelectiveBorders ? (
200
+ // For pieces with selective borders: render individual edge strokes with edge detection
201
+ (() => {
202
+ const allPiecesInSector = pieces.filter(piece => piece.sectorId === p.sectorId);
203
+ const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
204
+ const allPiecesAsPieces = allPiecesInSector.map(piece => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
205
+ const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
206
+
207
+ // Check if this piece's edges were hidden due to touching the dragged piece
208
+ const draggedPiece = draggingId ? allPiecesInSector.find(piece => piece.id === draggingId) : null;
209
+
210
+ // For composites, we need to distinguish between internal edges (always hidden) and external edges (shown when dragging)
211
+ let wasTouchingDraggedPiece: boolean[];
212
+ if (p.blueprintId.startsWith('comp:')) {
213
+ // For composites, check internal edges separately from external edges
214
+ const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
215
+ const externalHiddenEdges = draggedPiece ?
216
+ getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
217
+ new Array(hiddenEdges.length).fill(false);
218
+
219
+ // Only consider external edges as "touching dragged piece"
220
+ wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
221
+ } else {
222
+ // For primitives, all hidden edges are external
223
+ wasTouchingDraggedPiece = draggedPiece ?
224
+ getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
225
+ new Array(hiddenEdges.length).fill(false);
226
+ }
227
+
228
+ return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
229
+ // Show border if: 1) it wasn't hidden originally, OR 2) it was hidden due to touching the dragged piece
230
+ const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
231
+
232
+ // Special case: if this piece is being dragged and it's a composite, keep internal edges hidden
233
+ let isHidden: boolean;
234
+ if (isDragging && p.blueprintId.startsWith('comp:')) {
235
+ // For dragged composites, only show external edges (not internal ones)
236
+ const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
237
+ isHidden = internalHiddenEdges[strokeIdx] || false;
238
+ } else {
239
+ // Normal logic for non-dragged pieces or primitives
240
+ isHidden = isDragging ? false : ((hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece);
241
+ }
242
+
243
+ return (
244
+ <path
245
+ key={`stroke-${idx}-${strokeIdx}`}
246
+ d={strokePath}
247
+ fill="none"
248
+ stroke={isHidden ? "none" : (isConnectivityLocked ? CONFIG.color.piece.invalidStroke : (isCarriedInvalid ? CONFIG.color.piece.invalidStroke : (isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke)))}
249
+ strokeWidth={isHidden ? 0 : ((isSelected || isDragging) ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx)}
250
+ onPointerDown={(e) => onPiecePointerDown(e, p)}
251
+ />
252
+ );
253
+ });
254
+ })()
255
+ ) : (
256
+ // For primitives or composites without selective borders: render full border
257
+ <path
258
+ d={pathD(poly)}
259
+ fill="none"
260
+ stroke={isConnectivityLocked ? CONFIG.color.piece.invalidStroke : (isCarriedInvalid ? CONFIG.color.piece.invalidStroke : (isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke))}
261
+ strokeWidth={(isSelected || isDragging) ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx}
262
+ onPointerDown={(e) => onPiecePointerDown(e, p)}
263
+ />
264
+ )
265
+ )}
266
+ </React.Fragment>
267
+ );
268
+ })}
269
+ </g>
270
+ );
271
+ })}
272
+
273
+ {/* silhouettes (use pre-placed polys) */}
274
+ {layout.sectors.map((s) => {
275
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
276
+ if (!placedPolys.length) return null;
277
+ return (
278
+ <g key={`sil-${s.id}`} pointerEvents="none">
279
+ {placedPolys.map((poly, i) => (
280
+ <path key={i} d={pathD(poly)} fill={CONFIG.color.silhouetteMask} opacity={CONFIG.opacity.silhouetteMask} />
281
+ ))}
282
+ </g>
283
+ );
284
+ })}
285
+
286
+ {/* anchor grid */}
287
+ {anchorDots.map(({ sectorId, valid, invalid }) => {
288
+ const isInnerRing = sectorId === "inner-ring";
289
+ return (
290
+ <g key={`anchors-${sectorId}`} pointerEvents="none">
291
+ {invalid.map((p, i) => (
292
+ <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} />
293
+ ))}
294
+ {valid.map((p, i) => (
295
+ <circle
296
+ key={`val-${i}`}
297
+ cx={p.x}
298
+ cy={p.y}
299
+ r={isInnerRing ? CONFIG.size.anchorRadiusPx.invalid : CONFIG.size.anchorRadiusPx.valid}
300
+ fill={isInnerRing ? CONFIG.color.anchors.invalid : CONFIG.color.anchors.valid}
301
+ opacity={isInnerRing ? CONFIG.opacity.anchors.invalid : CONFIG.opacity.anchors.valid}
302
+ />
303
+ ))}
304
+ </g>
305
+ );
306
+ })}
307
+
308
+ {/* center badge */}
309
+ {(() => {
310
+ const isPrep = controller.state.cfg.mode === "prep";
311
+ const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
312
+ const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
313
+
314
+ return (
315
+ <g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
316
+ style={{ cursor: isClickable ? "pointer" : "default" }}
317
+ onPointerDown={isClickable ? onCenterBadgePointerDown : undefined}>
318
+ <circle r={badgeR}
319
+ fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
320
+ opacity={isSubmitEnabled ? 1.0 : 0.5} />
321
+ <text textAnchor="middle"
322
+ dominantBaseline="middle"
323
+ fontSize={CONFIG.size.badgeFontPx}
324
+ fill={isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888"}
325
+ pointerEvents="none">
326
+ {isPrep ? "Submit" : controller.state.blueprintView}
327
+ </text>
328
+ </g>
329
+ );
330
+ })()}
331
+
332
+ {/* blueprint glyphs */}
333
+ {bps.map((bp: any, i: number) => {
334
+ const theta = blueprintTheta(i);
335
+ const bx = layout.cx + blueprintRingR * Math.cos(theta);
336
+ const by = layout.cy + blueprintRingR * Math.sin(theta);
337
+ return renderBlueprintGlyph(bp, bx, by);
338
+ })}
339
+
340
+
341
+ {/* all green pulse */}
342
+ {controller.state.endedAt && (
343
+ <g pointerEvents="none">
344
+ <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}>
345
+ <animate attributeName="opacity" from="0" to="1" dur="160ms" fill="freeze" />
346
+ <animate attributeName="opacity" from="1" to="0" begin="560ms" dur="520ms" fill="freeze" />
347
+ </circle>
348
+ </g>
349
+ )}
350
+ </svg>
351
+ );
352
+ }