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
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
|
+
}
|