jspsych-tangram 0.0.11 → 0.0.13

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 (46) hide show
  1. package/dist/construct/index.browser.js +4805 -3935
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +13 -13
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +289 -71
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +36 -0
  8. package/dist/construct/index.js +289 -71
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +399 -100
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +84 -0
  13. package/dist/index.js +399 -100
  14. package/dist/index.js.map +1 -1
  15. package/dist/nback/index.browser.js +4629 -3939
  16. package/dist/nback/index.browser.js.map +1 -1
  17. package/dist/nback/index.browser.min.js +12 -12
  18. package/dist/nback/index.browser.min.js.map +1 -1
  19. package/dist/nback/index.cjs +102 -64
  20. package/dist/nback/index.cjs.map +1 -1
  21. package/dist/nback/index.d.ts +24 -0
  22. package/dist/nback/index.js +102 -64
  23. package/dist/nback/index.js.map +1 -1
  24. package/dist/prep/index.browser.js +4803 -3941
  25. package/dist/prep/index.browser.js.map +1 -1
  26. package/dist/prep/index.browser.min.js +13 -13
  27. package/dist/prep/index.browser.min.js.map +1 -1
  28. package/dist/prep/index.cjs +285 -75
  29. package/dist/prep/index.cjs.map +1 -1
  30. package/dist/prep/index.d.ts +24 -0
  31. package/dist/prep/index.js +285 -75
  32. package/dist/prep/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/core/components/board/BoardView.tsx +372 -124
  35. package/src/core/components/board/GameBoard.tsx +128 -91
  36. package/src/core/components/pieces/BlueprintRing.tsx +105 -47
  37. package/src/core/config/config.ts +25 -10
  38. package/src/plugins/tangram-construct/ConstructionApp.tsx +7 -1
  39. package/src/plugins/tangram-construct/index.ts +22 -1
  40. package/src/plugins/tangram-nback/NBackApp.tsx +87 -28
  41. package/src/plugins/tangram-nback/index.ts +14 -0
  42. package/src/plugins/tangram-prep/PrepApp.tsx +7 -1
  43. package/src/plugins/tangram-prep/index.ts +14 -0
  44. package/tangram-construct.min.js +13 -13
  45. package/tangram-nback.min.js +12 -12
  46. package/tangram-prep.min.js +13 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych-tangram",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import type { Poly, TanKind, Blueprint, PrimitiveBlueprint } from "@/core/domain/types";
3
3
  import { wedgePath, rectForBand, type CircleLayout } from "@/core/domain/layout";
4
- import { boundsOfBlueprint, placeSilhouetteGridAlignedAsPolys } from "@/core/engine/geometry";
4
+ import { boundsOfBlueprint, placeSilhouetteGridAlignedAsPolys, polysAABB } from "@/core/engine/geometry";
5
5
  import { CONFIG } from "@/core/config/config";
6
6
  import { generateEdgeStrokePaths, shouldUseSelectiveBorders, shouldShowBorders, getHiddenEdgesForPolygon } from "@/core/engine/validation/border-rendering";
7
7
 
@@ -34,6 +34,9 @@ export type BoardViewProps = {
34
34
  dragInvalid: boolean;
35
35
  lockedPieceId: string | null;
36
36
  showTangramDecomposition?: boolean; // whether to show tangram decomposed into primitives
37
+ usePrimitiveColorsBlueprints?: boolean; // whether to use distinct colors for blueprints in dock
38
+ usePrimitiveColorsTargets?: boolean; // whether to use distinct colors for target tangrams
39
+ primitiveColorIndices?: number[]; // array of 5 integers mapping primitives to color indices
37
40
 
38
41
  // refs + handlers wired by the container
39
42
  svgRef: React.RefObject<SVGSVGElement>;
@@ -57,124 +60,138 @@ function pathD(poly: Poly) {
57
60
  return `M ${poly.map((pt) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
58
61
  }
59
62
 
63
+ /**
64
+ * Get the fill color for a piece or blueprint based on its type
65
+ *
66
+ * REQUIRES: blueprint is valid, primitiveColorIndices is an array of 5 valid indices
67
+ * EFFECTS: Returns color based on primitive type if usePrimitiveColors is true,
68
+ * otherwise returns the default color. Composites always use default color.
69
+ */
70
+ function getPieceColor(
71
+ blueprint: Blueprint,
72
+ usePrimitiveColors: boolean,
73
+ defaultColor: string,
74
+ primitiveColorIndices: number[]
75
+ ): string {
76
+ if (!usePrimitiveColors) {
77
+ return defaultColor;
78
+ }
79
+
80
+ // For primitive blueprints, use the mapped color from CONFIG
81
+ if ('kind' in blueprint) {
82
+ const kind = blueprint.kind as TanKind;
83
+ // Map primitive kind to index: square=0, smalltriangle=1, parallelogram=2, medtriangle=3, largetriangle=4
84
+ const kindToIndex: Record<TanKind, number> = {
85
+ 'square': 0,
86
+ 'smalltriangle': 1,
87
+ 'parallelogram': 2,
88
+ 'medtriangle': 3,
89
+ 'largetriangle': 4
90
+ };
91
+ const primitiveIndex = kindToIndex[kind];
92
+ if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
93
+ const colorIndex = primitiveColorIndices[primitiveIndex];
94
+ const color = CONFIG.color.primitiveColors[colorIndex];
95
+ if (color) {
96
+ return color;
97
+ }
98
+ }
99
+ return defaultColor;
100
+ }
101
+
102
+ // For composite blueprints, always use the default color
103
+ return defaultColor;
104
+ }
105
+
60
106
  export default function BoardView(props: BoardViewProps) {
61
107
  const {
62
108
  controller, layout, viewBox, width, height,
63
109
  badgeR, badgeCenter,
64
110
  placedSilBySector, anchorDots, pieces, scaleS,
65
111
  clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId, showTangramDecomposition,
112
+ usePrimitiveColorsBlueprints, usePrimitiveColorsTargets, primitiveColorIndices,
66
113
  svgRef, setPieceRef,
67
114
  onPiecePointerDown, onBlueprintPointerDown, onRootPointerDown, onPointerMove, onPointerUp, onCenterBadgePointerDown,
68
115
  } = props;
69
116
 
70
117
  const VW = viewBox.w, VH = viewBox.h;
71
118
 
72
- // blueprint ring geometry
73
- const centerView = controller.state.blueprintView;
74
- const bps: Blueprint[] =
75
- centerView === "primitives"
76
- ? (controller.state.primitives as PrimitiveBlueprint[])
77
- : (controller.state.quickstash as Blueprint[]);
78
-
79
- const QS_SLOTS = controller.state.cfg.maxQuickstashSlots;
80
- const PRIM_SLOTS = controller.state.primitives.length;
81
- const slotsForView = centerView === "primitives" ? Math.max(1, PRIM_SLOTS) : Math.max(1, QS_SLOTS);
82
- const sweep = layout.mode === "circle" ? Math.PI * 2 : Math.PI;
83
- const delta = sweep / slotsForView;
84
-
85
- const start = layout.mode === "circle" ? -Math.PI / 2 : Math.PI;
86
- const blueprintTheta = (i: number) => start + (i + 0.5) * delta;
87
-
88
- // chord requirement (reuse same logic as container already used for radii)
89
- const anchorsDiameterToPx = (anchorsDiag: number, gridPx: number = CONFIG.layout.grid.stepPx) =>
90
- anchorsDiag * Math.SQRT2 * gridPx;
91
- const reqAnchors = centerView === "primitives"
92
- ? CONFIG.layout.constraints.primitiveDiamAnchors
93
- : CONFIG.layout.constraints.quickstashDiamAnchors;
94
- const D_slot = anchorsDiameterToPx(reqAnchors);
95
- const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
96
-
97
- // Minimum radius to avoid overlapping with badge
98
- // Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
99
- const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
100
- const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
101
-
102
- // Clamp to [R_min, ringMax]
103
- const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
104
-
105
- const renderBlueprintGlyph = (bp: Blueprint, bx: number, by: number) => {
106
- const bb = boundsOfBlueprint(bp, (k: string) => controller.getPrimitive(k as TanKind)!);
107
- const cx = bb.min.x + bb.width / 2;
108
- const cy = bb.min.y + bb.height / 2;
109
- const selected = false; // container highlights via pending state; optional
110
-
111
- return (
112
- <g
113
- key={bp.id}
114
- transform={`translate(${bx}, ${by}) scale(1) translate(${-cx}, ${-cy})`}
115
- >
116
- {("shape" in bp ? bp.shape : []).map((poly, idx) => (
117
- <path
118
- key={idx}
119
- d={pathD(poly)}
120
- fill={CONFIG.color.blueprint.fill}
121
- opacity={CONFIG.opacity.blueprint}
122
- stroke={selected ? CONFIG.color.blueprint.selectedStroke : "none"}
123
- strokeWidth={selected ? 2 : 0}
124
- pointerEvents="visiblePainted"
125
- style={{ cursor: "pointer" }}
126
- onPointerDown={(e) => onBlueprintPointerDown(e as any, bp, { bx, by, cx, cy })}
127
- />
128
- ))}
129
- </g>
130
- );
131
- };
132
-
133
- return (
134
- <svg
135
- ref={svgRef}
136
- width={width}
137
- height={height}
138
- viewBox={`0 0 ${VW} ${VH}`}
139
- preserveAspectRatio="xMidYMid meet"
140
- onPointerMove={onPointerMove}
141
- onPointerUp={onPointerUp}
142
- onPointerDown={(e) => {
143
- onRootPointerDown(e);
144
- }}
145
- style={{ background: "#f5f5f5", touchAction: "none", userSelect: "none" }}
146
- >
147
- {/* bands as wedges */}
148
- {layout.sectors.map((s, i) => {
149
- const done = !!controller.state.sectors[s.id].completedAt;
150
- const baseSil = i % 2 ? CONFIG.color.bands.silhouette.fillOdd : CONFIG.color.bands.silhouette.fillEven;
151
- const baseWork = i % 2 ? CONFIG.color.bands.workspace.fillOdd : CONFIG.color.bands.workspace.fillEven;
152
- const sil = layout.bands.silhouette;
153
- const work = layout.bands.workspace;
154
- return (
155
- <g key={`bands-${s.id}`}>
156
- {controller.state.cfg.target === "workspace" ? (
157
- <>
158
- <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" />
159
- <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" />
160
- </>
161
- ) : (
162
- <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" />
163
- )}
164
- </g>
165
- );
166
- })}
167
-
168
-
169
- {/* live pieces - MOVED ABOVE SILHOUETTES FOR TESTING */}
170
- {pieces
171
- .sort((a, b) => {
172
- // Sort so that dragged piece is rendered last (on top)
173
- if (draggingId === a.id) return 1;
174
- if (draggingId === b.id) return -1;
175
- return 0;
176
- })
177
- .map((p) => {
119
+ // Helper function to render silhouettes
120
+ const renderSilhouettes = () => layout.sectors.map((s) => {
121
+ const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
122
+
123
+ // Check if we should render decomposed primitives
124
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
125
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
126
+
127
+ // Use same positioning logic as unified silhouette
128
+ const rect = rectForBand(layout, s, "silhouette", 1.0);
129
+ const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
130
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
131
+
132
+ return (
133
+ <g key={`sil-decomposed-${s.id}`} pointerEvents="none">
134
+ {placedPolys.map((scaledPoly, i) => {
135
+ // Get color for this primitive based on its kind
136
+ const primInfo = primitiveDecomposition[i];
137
+ let fillColor = CONFIG.color.silhouetteMask;
138
+
139
+ if (usePrimitiveColorsTargets && primInfo?.kind && primitiveColorIndices) {
140
+ // Map primitive kind to index
141
+ const kindToIndex: Record<TanKind, number> = {
142
+ 'square': 0,
143
+ 'smalltriangle': 1,
144
+ 'parallelogram': 2,
145
+ 'medtriangle': 3,
146
+ 'largetriangle': 4
147
+ };
148
+ const primitiveIndex = kindToIndex[primInfo.kind as TanKind];
149
+ if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
150
+ const colorIndex = primitiveColorIndices[primitiveIndex];
151
+ const color = CONFIG.color.primitiveColors[colorIndex];
152
+ if (color) {
153
+ fillColor = color;
154
+ }
155
+ }
156
+ }
157
+
158
+ return (
159
+ <path
160
+ key={`prim-fill-${i}`}
161
+ d={pathD(scaledPoly)}
162
+ fill={fillColor}
163
+ opacity={CONFIG.opacity.silhouetteMask}
164
+ stroke="none"
165
+ />
166
+ );
167
+ })}
168
+ </g>
169
+ );
170
+ } else {
171
+ // Default: render unified silhouette
172
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
173
+ if (!placedPolys.length) return null;
174
+ return (
175
+ <g key={`sil-${s.id}`} pointerEvents="none">
176
+ {placedPolys.map((poly, i) => (
177
+ <path key={i} d={pathD(poly)} fill={CONFIG.color.silhouetteMask} opacity={CONFIG.opacity.silhouetteMask} />
178
+ ))}
179
+ </g>
180
+ );
181
+ }
182
+ });
183
+
184
+ // Helper function to render pieces (with optional filter)
185
+ const renderPieces = (piecesFilter?: (p: PieceView) => boolean) => {
186
+ const piecesToRender = piecesFilter ? pieces.filter(piecesFilter) : pieces;
187
+ return piecesToRender
188
+ .sort((a, b) => {
189
+ // Sort so that dragged piece is rendered last (on top)
190
+ if (draggingId === a.id) return 1;
191
+ if (draggingId === b.id) return -1;
192
+ return 0;
193
+ })
194
+ .map((p) => {
178
195
  const bp = controller.getBlueprint(p.blueprintId)!;
179
196
  const bb = boundsOfBlueprint(bp, (k: string) => controller.getPrimitive(k as TanKind)!);
180
197
  const isDragging = draggingId === p.id;
@@ -184,6 +201,20 @@ export default function BoardView(props: BoardViewProps) {
184
201
  const isCarriedInvalid = isDragging && dragInvalid;
185
202
  const translateX = p.x - bb.min.x;
186
203
  const translateY = p.y - bb.min.y;
204
+
205
+ const validFillColor = getPieceColor(
206
+ bp,
207
+ usePrimitiveColorsBlueprints || false,
208
+ CONFIG.color.piece.validFill,
209
+ primitiveColorIndices || [0, 1, 2, 3, 4]
210
+ );
211
+ const draggingFillColor = getPieceColor(
212
+ bp,
213
+ usePrimitiveColorsBlueprints || false,
214
+ CONFIG.color.piece.draggingFill,
215
+ primitiveColorIndices || [0, 1, 2, 3, 4]
216
+ );
217
+
187
218
  return (
188
219
  <g key={p.id} ref={setPieceRef(p.id)} transform={`translate(${translateX}, ${translateY})`} style={{ cursor: locked ? "default" : clickMode ? "pointer" : "grab" }} pointerEvents={clickMode && isDragging ? "none" : "auto"}>
189
220
  {("shape" in bp ? bp.shape : []).map((poly: any, idx: number) => {
@@ -194,7 +225,7 @@ export default function BoardView(props: BoardViewProps) {
194
225
  {/* Fill path - always rendered with no borders */}
195
226
  <path
196
227
  d={pathD(poly)}
197
- fill={isConnectivityLocked ? CONFIG.color.piece.invalidFill : (isCarriedInvalid ? CONFIG.color.piece.invalidFill : (isDragging ? CONFIG.color.piece.draggingFill : CONFIG.color.piece.validFill))}
228
+ fill={isConnectivityLocked ? CONFIG.color.piece.invalidFill : (isCarriedInvalid ? CONFIG.color.piece.invalidFill : (isDragging ? draggingFillColor : validFillColor))}
198
229
  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)))}
199
230
  stroke="none"
200
231
  onPointerDown={(e) => onPiecePointerDown(e, p)}
@@ -272,11 +303,188 @@ export default function BoardView(props: BoardViewProps) {
272
303
  </React.Fragment>
273
304
  );
274
305
  })}
306
+
307
+ {/* Invalid marker 'X' - shown at piece center when dragging invalid piece */}
308
+ {isDragging && isCarriedInvalid && "shape" in bp && bp.shape.length > 0 && (() => {
309
+ const { cx, cy } = polysAABB(bp.shape);
310
+ const size = CONFIG.size.invalidMarker.sizePx;
311
+ const strokeWidth = CONFIG.size.invalidMarker.strokePx;
312
+ const borderWidth = strokeWidth + 2;
313
+
314
+ return (
315
+ <g key="invalid-marker">
316
+ {/* White border for first line */}
317
+ <line
318
+ x1={cx - size}
319
+ y1={cy - size}
320
+ x2={cx + size}
321
+ y2={cy + size}
322
+ stroke="white"
323
+ strokeWidth={borderWidth}
324
+ strokeLinecap="round"
325
+ />
326
+ {/* Red interior for first line */}
327
+ <line
328
+ x1={cx - size}
329
+ y1={cy - size}
330
+ x2={cx + size}
331
+ y2={cy + size}
332
+ stroke={CONFIG.color.piece.invalidStroke}
333
+ strokeWidth={strokeWidth}
334
+ strokeLinecap="round"
335
+ />
336
+ {/* White border for second line */}
337
+ <line
338
+ x1={cx + size}
339
+ y1={cy - size}
340
+ x2={cx - size}
341
+ y2={cy + size}
342
+ stroke="white"
343
+ strokeWidth={borderWidth}
344
+ strokeLinecap="round"
345
+ />
346
+ {/* Red interior for second line */}
347
+ <line
348
+ x1={cx + size}
349
+ y1={cy - size}
350
+ x2={cx - size}
351
+ y2={cy + size}
352
+ stroke={CONFIG.color.piece.invalidStroke}
353
+ strokeWidth={strokeWidth}
354
+ strokeLinecap="round"
355
+ />
356
+ </g>
357
+ );
358
+ })()}
359
+ </g>
360
+ );
361
+ });
362
+ };
363
+
364
+ // blueprint ring geometry
365
+ const centerView = controller.state.blueprintView;
366
+ const bps: Blueprint[] =
367
+ centerView === "primitives"
368
+ ? (controller.state.primitives as PrimitiveBlueprint[])
369
+ : (controller.state.quickstash as Blueprint[]);
370
+
371
+ const QS_SLOTS = controller.state.cfg.maxQuickstashSlots;
372
+ const PRIM_SLOTS = controller.state.primitives.length;
373
+ const slotsForView = centerView === "primitives" ? Math.max(1, PRIM_SLOTS) : Math.max(1, QS_SLOTS);
374
+ const sweep = layout.mode === "circle" ? Math.PI * 2 : Math.PI;
375
+ const delta = sweep / slotsForView;
376
+
377
+ const start = layout.mode === "circle" ? -Math.PI / 2 : Math.PI;
378
+ const blueprintTheta = (i: number) => start + (i + 0.5) * delta;
379
+
380
+ // chord requirement (reuse same logic as container already used for radii)
381
+ const anchorsDiameterToPx = (anchorsDiag: number, gridPx: number = CONFIG.layout.grid.stepPx) =>
382
+ anchorsDiag * Math.SQRT2 * gridPx;
383
+ const reqAnchors = centerView === "primitives"
384
+ ? CONFIG.layout.constraints.primitiveDiamAnchors
385
+ : CONFIG.layout.constraints.quickstashDiamAnchors;
386
+ const D_slot = anchorsDiameterToPx(reqAnchors);
387
+ const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
388
+
389
+ // Minimum radius to avoid overlapping with badge
390
+ // Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
391
+ const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
392
+ const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
393
+
394
+ // Clamp to [R_min, ringMax]
395
+ const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
396
+
397
+ const renderBlueprintGlyph = (bp: Blueprint, bx: number, by: number) => {
398
+ const bb = boundsOfBlueprint(bp, (k: string) => controller.getPrimitive(k as TanKind)!);
399
+ const cx = bb.min.x + bb.width / 2;
400
+ const cy = bb.min.y + bb.height / 2;
401
+ const selected = false; // container highlights via pending state; optional
402
+
403
+ const fillColor = getPieceColor(
404
+ bp,
405
+ usePrimitiveColorsBlueprints || false,
406
+ CONFIG.color.blueprint.fill,
407
+ primitiveColorIndices || [0, 1, 2, 3, 4]
408
+ );
409
+
410
+ return (
411
+ <g
412
+ key={bp.id}
413
+ transform={`translate(${bx}, ${by}) scale(1) translate(${-cx}, ${-cy})`}
414
+ >
415
+ {("shape" in bp ? bp.shape : []).map((poly, idx) => (
416
+ <path
417
+ key={idx}
418
+ d={pathD(poly)}
419
+ fill={fillColor}
420
+ opacity={CONFIG.opacity.blueprint}
421
+ stroke={selected ? CONFIG.color.blueprint.selectedStroke : "none"}
422
+ strokeWidth={selected ? 2 : 0}
423
+ pointerEvents="visiblePainted"
424
+ style={{ cursor: "pointer" }}
425
+ onPointerDown={(e) => onBlueprintPointerDown(e as any, bp, { bx, by, cx, cy })}
426
+ />
427
+ ))}
428
+ </g>
429
+ );
430
+ };
431
+
432
+ return (
433
+ <svg
434
+ ref={svgRef}
435
+ width={width}
436
+ height={height}
437
+ viewBox={`0 0 ${VW} ${VH}`}
438
+ preserveAspectRatio="xMidYMid meet"
439
+ onPointerMove={onPointerMove}
440
+ onPointerUp={onPointerUp}
441
+ onPointerDown={(e) => {
442
+ onRootPointerDown(e);
443
+ }}
444
+ style={{ background: CONFIG.color.background, touchAction: "none", userSelect: "none" }}
445
+ >
446
+ {/* bands as wedges */}
447
+ {layout.sectors.map((s, i) => {
448
+ const done = !!controller.state.sectors[s.id].completedAt;
449
+ const baseSil = i % 2 ? CONFIG.color.bands.silhouette.fillOdd : CONFIG.color.bands.silhouette.fillEven;
450
+ const baseWork = i % 2 ? CONFIG.color.bands.workspace.fillOdd : CONFIG.color.bands.workspace.fillEven;
451
+ const sil = layout.bands.silhouette;
452
+ const work = layout.bands.workspace;
453
+ return (
454
+ <g key={`bands-${s.id}`}>
455
+ {controller.state.cfg.target === "workspace" ? (
456
+ <>
457
+ <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" />
458
+ <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" />
459
+ </>
460
+ ) : (
461
+ <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" />
462
+ )}
275
463
  </g>
276
464
  );
277
465
  })}
278
466
 
279
- {/* silhouettes (use pre-placed polys) */}
467
+
468
+ {/* Conditional rendering based on silhouettesBelowPieces config */}
469
+ {CONFIG.game.silhouettesBelowPieces ? (
470
+ <>
471
+ {/* Render silhouettes first (below all pieces) */}
472
+ {renderSilhouettes()}
473
+
474
+ {/* Then all pieces */}
475
+ {renderPieces()}
476
+ </>
477
+ ) : (
478
+ <>
479
+ {/* Render all pieces first */}
480
+ {renderPieces()}
481
+
482
+ {/* Then silhouettes (above all pieces) */}
483
+ {renderSilhouettes()}
484
+ </>
485
+ )}
486
+
487
+ {/* silhouettes (use pre-placed polys) - rendered first so pieces appear on top */}
280
488
  {layout.sectors.map((s) => {
281
489
  const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
282
490
 
@@ -291,25 +499,40 @@ export default function BoardView(props: BoardViewProps) {
291
499
 
292
500
  return (
293
501
  <g key={`sil-decomposed-${s.id}`} pointerEvents="none">
294
- {placedPolys.map((scaledPoly, i) => (
295
- <React.Fragment key={`prim-${i}`}>
296
- {/* Fill path */}
297
- <path
298
- d={pathD(scaledPoly)}
299
- fill={CONFIG.color.silhouetteMask}
300
- opacity={CONFIG.opacity.silhouetteMask}
301
- stroke="none"
302
- />
303
-
304
- {/* Full perimeter border */}
502
+ {placedPolys.map((scaledPoly, i) => {
503
+ // Get color for this primitive based on its kind
504
+ const primInfo = primitiveDecomposition[i];
505
+ let fillColor = CONFIG.color.silhouetteMask;
506
+
507
+ if (usePrimitiveColorsTargets && primInfo?.kind && primitiveColorIndices) {
508
+ // Map primitive kind to index
509
+ const kindToIndex: Record<TanKind, number> = {
510
+ 'square': 0,
511
+ 'smalltriangle': 1,
512
+ 'parallelogram': 2,
513
+ 'medtriangle': 3,
514
+ 'largetriangle': 4
515
+ };
516
+ const primitiveIndex = kindToIndex[primInfo.kind as TanKind];
517
+ if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
518
+ const colorIndex = primitiveColorIndices[primitiveIndex];
519
+ const color = CONFIG.color.primitiveColors[colorIndex];
520
+ if (color) {
521
+ fillColor = color;
522
+ }
523
+ }
524
+ }
525
+
526
+ return (
305
527
  <path
528
+ key={`prim-fill-${i}`}
306
529
  d={pathD(scaledPoly)}
307
530
  fill="none"
308
- stroke={CONFIG.color.tangramDecomposition.stroke}
309
- strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
531
+ opacity={0}
532
+ stroke="none"
310
533
  />
311
- </React.Fragment>
312
- ))}
534
+ );
535
+ })}
313
536
  </g>
314
537
  );
315
538
  } else {
@@ -429,6 +652,31 @@ export default function BoardView(props: BoardViewProps) {
429
652
  </circle>
430
653
  </g>
431
654
  )}
655
+
656
+ {/* tangram decomposition borders - rendered last so they appear on top of pieces */}
657
+ {showTangramDecomposition && layout.sectors.map((s) => {
658
+ const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
659
+ if (!sectorCfg?.silhouette.primitiveDecomposition) return null;
660
+
661
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
662
+ const rect = rectForBand(layout, s, "silhouette", 1.0);
663
+ const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
664
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
665
+
666
+ return (
667
+ <g key={`sil-borders-${s.id}`} pointerEvents="none">
668
+ {placedPolys.map((scaledPoly, i) => (
669
+ <path
670
+ key={`prim-border-${i}`}
671
+ d={pathD(scaledPoly)}
672
+ fill="none"
673
+ stroke={CONFIG.color.tangramDecomposition.stroke}
674
+ strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
675
+ />
676
+ ))}
677
+ </g>
678
+ );
679
+ })}
432
680
  </svg>
433
681
  );
434
682
  }