jspsych-tangram 0.0.12 → 0.0.14

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 (49) hide show
  1. package/dist/construct/index.browser.js +283 -64
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +11 -11
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +283 -64
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +36 -0
  8. package/dist/construct/index.js +283 -64
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +394 -94
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +84 -0
  13. package/dist/index.js +394 -94
  14. package/dist/index.js.map +1 -1
  15. package/dist/nback/index.browser.js +112 -37
  16. package/dist/nback/index.browser.js.map +1 -1
  17. package/dist/nback/index.browser.min.js +11 -11
  18. package/dist/nback/index.browser.min.js.map +1 -1
  19. package/dist/nback/index.cjs +112 -37
  20. package/dist/nback/index.cjs.map +1 -1
  21. package/dist/nback/index.d.ts +24 -0
  22. package/dist/nback/index.js +112 -37
  23. package/dist/nback/index.js.map +1 -1
  24. package/dist/prep/index.browser.js +278 -65
  25. package/dist/prep/index.browser.js.map +1 -1
  26. package/dist/prep/index.browser.min.js +11 -11
  27. package/dist/prep/index.browser.min.js.map +1 -1
  28. package/dist/prep/index.cjs +278 -65
  29. package/dist/prep/index.cjs.map +1 -1
  30. package/dist/prep/index.d.ts +24 -0
  31. package/dist/prep/index.js +278 -65
  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 +39 -44
  36. package/src/core/components/board/useGameBoard.ts +52 -0
  37. package/src/core/components/index.ts +4 -2
  38. package/src/core/components/pieces/BlueprintRing.tsx +100 -67
  39. package/src/core/components/pieces/useBlueprintRing.ts +39 -0
  40. package/src/core/config/config.ts +25 -10
  41. package/src/plugins/tangram-construct/ConstructionApp.tsx +7 -1
  42. package/src/plugins/tangram-construct/index.ts +22 -1
  43. package/src/plugins/tangram-nback/NBackApp.tsx +88 -29
  44. package/src/plugins/tangram-nback/index.ts +14 -0
  45. package/src/plugins/tangram-prep/PrepApp.tsx +7 -1
  46. package/src/plugins/tangram-prep/index.ts +14 -0
  47. package/tangram-construct.min.js +11 -11
  48. package/tangram-nback.min.js +11 -11
  49. package/tangram-prep.min.js +11 -11
@@ -62,6 +62,18 @@ declare const info: {
62
62
  type: ParameterType;
63
63
  default: undefined;
64
64
  };
65
+ /** Whether to use distinct colors for each primitive shape type in blueprints */
66
+ use_primitive_colors_blueprints: {
67
+ type: ParameterType;
68
+ default: boolean;
69
+ description: string;
70
+ };
71
+ /** Indices mapping primitives to colors from the color palette */
72
+ primitive_color_indices: {
73
+ type: ParameterType;
74
+ default: number[];
75
+ description: string;
76
+ };
65
77
  };
66
78
  data: {
67
79
  /** Completion status */
@@ -143,6 +155,18 @@ declare class TangramPrepPlugin implements JsPsychPlugin<Info> {
143
155
  type: ParameterType;
144
156
  default: undefined;
145
157
  };
158
+ /** Whether to use distinct colors for each primitive shape type in blueprints */
159
+ use_primitive_colors_blueprints: {
160
+ type: ParameterType;
161
+ default: boolean;
162
+ description: string;
163
+ };
164
+ /** Indices mapping primitives to colors from the color palette */
165
+ primitive_color_indices: {
166
+ type: ParameterType;
167
+ default: number[];
168
+ description: string;
169
+ };
146
170
  };
147
171
  data: {
148
172
  /** Completion status */
@@ -5,30 +5,41 @@ import { v4 } from 'uuid';
5
5
 
6
6
  const CONFIG = {
7
7
  color: {
8
+ background: "#fff7e0ff",
8
9
  bands: {
9
- silhouette: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" },
10
- workspace: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" }
10
+ silhouette: { fillEven: "#ffffff", fillOdd: "#ffffff", stroke: "#b1b1b1" },
11
+ workspace: { fillEven: "#ffffff", fillOdd: "#ffffff", stroke: "#b1b1b1" }
11
12
  },
12
- completion: { fill: "#ccfff2", stroke: "#13da57" },
13
+ completion: { fill: "#ccffcc", stroke: "#13da57" },
13
14
  silhouetteMask: "#374151",
14
15
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
15
- piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
16
+ // validFill used here for placed composites
17
+ piece: { draggingFill: "#8e7cc3", validFill: "#8e7cc3", invalidFill: "#d55c00", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
16
18
  ui: { light: "#60a5fa", dark: "#1d4ed8" },
17
19
  blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
18
- tangramDecomposition: { stroke: "#fef2cc" }
20
+ tangramDecomposition: { stroke: "#fef2cc" },
21
+ primitiveColors: [
22
+ // from seaborn "colorblind" palette, 6 colors, with red omitted
23
+ "#0173b2",
24
+ "#de8f05",
25
+ "#029e73",
26
+ "#cc78bc",
27
+ "#ca9161"
28
+ ]
19
29
  },
20
30
  opacity: {
21
- blueprint: 0.4,
31
+ blueprint: 0.6,
22
32
  silhouetteMask: 0.25,
23
33
  //anchors: { valid: 0.80, invalid: 0.50 },
24
34
  anchors: { invalid: 0, valid: 0 },
25
- piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
35
+ piece: { invalid: 1, dragging: 1, locked: 1, normal: 1 }
26
36
  },
27
37
  size: {
28
- stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
38
+ stroke: { bandPx: 5, pieceSelectedPx: 5, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
29
39
  anchorRadiusPx: { valid: 1, invalid: 1 },
30
40
  badgeFontPx: 16,
31
- centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
41
+ centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 },
42
+ invalidMarker: { sizePx: 10, strokePx: 4 }
32
43
  },
33
44
  layout: {
34
45
  grid: { stepPx: 20, unitPx: 40 },
@@ -46,7 +57,8 @@ const CONFIG = {
46
57
  game: {
47
58
  snapRadiusPx: 15,
48
59
  showBorders: false,
49
- hideTouchingBorders: true
60
+ hideTouchingBorders: true,
61
+ silhouettesBelowPieces: true
50
62
  }
51
63
  };
52
64
 
@@ -830,6 +842,31 @@ var unlockedIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAYAAA
830
842
  function pathD(poly) {
831
843
  return `M ${poly.map((pt) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
832
844
  }
845
+ function getPieceColor(blueprint, usePrimitiveColors, defaultColor, primitiveColorIndices) {
846
+ if (!usePrimitiveColors) {
847
+ return defaultColor;
848
+ }
849
+ if ("kind" in blueprint) {
850
+ const kind = blueprint.kind;
851
+ const kindToIndex = {
852
+ "square": 0,
853
+ "smalltriangle": 1,
854
+ "parallelogram": 2,
855
+ "medtriangle": 3,
856
+ "largetriangle": 4
857
+ };
858
+ const primitiveIndex = kindToIndex[kind];
859
+ if (primitiveIndex !== void 0 && primitiveColorIndices[primitiveIndex] !== void 0) {
860
+ const colorIndex = primitiveColorIndices[primitiveIndex];
861
+ const color = CONFIG.color.primitiveColors[colorIndex];
862
+ if (color) {
863
+ return color;
864
+ }
865
+ }
866
+ return defaultColor;
867
+ }
868
+ return defaultColor;
869
+ }
833
870
  function BoardView(props) {
834
871
  const {
835
872
  controller,
@@ -849,6 +886,9 @@ function BoardView(props) {
849
886
  dragInvalid,
850
887
  lockedPieceId,
851
888
  showTangramDecomposition,
889
+ usePrimitiveColorsBlueprints,
890
+ usePrimitiveColorsTargets,
891
+ primitiveColorIndices,
852
892
  svgRef,
853
893
  setPieceRef,
854
894
  onPiecePointerDown,
@@ -859,6 +899,144 @@ function BoardView(props) {
859
899
  onCenterBadgePointerDown
860
900
  } = props;
861
901
  const VW = viewBox.w, VH = viewBox.h;
902
+ const renderSilhouettes = () => layout.sectors.map((s) => {
903
+ const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
904
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
905
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
906
+ const rect = rectForBand(layout, s, "silhouette", 1);
907
+ const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
908
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
909
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-decomposed-${s.id}`, pointerEvents: "none" }, placedPolys.map((scaledPoly, i) => {
910
+ const primInfo = primitiveDecomposition[i];
911
+ let fillColor = CONFIG.color.silhouetteMask;
912
+ if (usePrimitiveColorsTargets && primInfo?.kind && primitiveColorIndices) {
913
+ const kindToIndex = {
914
+ "square": 0,
915
+ "smalltriangle": 1,
916
+ "parallelogram": 2,
917
+ "medtriangle": 3,
918
+ "largetriangle": 4
919
+ };
920
+ const primitiveIndex = kindToIndex[primInfo.kind];
921
+ if (primitiveIndex !== void 0 && primitiveColorIndices[primitiveIndex] !== void 0) {
922
+ const colorIndex = primitiveColorIndices[primitiveIndex];
923
+ const color = CONFIG.color.primitiveColors[colorIndex];
924
+ if (color) {
925
+ fillColor = color;
926
+ }
927
+ }
928
+ }
929
+ return /* @__PURE__ */ React.createElement(
930
+ "path",
931
+ {
932
+ key: `prim-fill-${i}`,
933
+ d: pathD(scaledPoly),
934
+ fill: fillColor,
935
+ opacity: CONFIG.opacity.silhouetteMask,
936
+ stroke: "none"
937
+ }
938
+ );
939
+ }));
940
+ } else {
941
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
942
+ if (!placedPolys.length) return null;
943
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-${s.id}`, pointerEvents: "none" }, placedPolys.map((poly, i) => /* @__PURE__ */ React.createElement("path", { key: i, d: pathD(poly), fill: CONFIG.color.silhouetteMask, opacity: CONFIG.opacity.silhouetteMask })));
944
+ }
945
+ });
946
+ const renderPieces = (piecesFilter) => {
947
+ const piecesToRender = pieces;
948
+ return piecesToRender.sort((a, b) => {
949
+ if (draggingId === a.id) return 1;
950
+ if (draggingId === b.id) return -1;
951
+ return 0;
952
+ }).map((p) => {
953
+ const bp = controller.getBlueprint(p.blueprintId);
954
+ const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
955
+ const isDragging = draggingId === p.id;
956
+ const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
957
+ const isConnectivityLocked = lockedPieceId === p.id;
958
+ selectedPieceId === p.id;
959
+ const isCarriedInvalid = isDragging && dragInvalid;
960
+ const translateX = p.x - bb.min.x;
961
+ const translateY = p.y - bb.min.y;
962
+ const validFillColor = getPieceColor(
963
+ bp,
964
+ usePrimitiveColorsBlueprints || false,
965
+ CONFIG.color.piece.validFill,
966
+ primitiveColorIndices || [0, 1, 2, 3, 4]
967
+ );
968
+ const draggingFillColor = getPieceColor(
969
+ bp,
970
+ usePrimitiveColorsBlueprints || false,
971
+ CONFIG.color.piece.draggingFill,
972
+ primitiveColorIndices || [0, 1, 2, 3, 4]
973
+ );
974
+ return /* @__PURE__ */ React.createElement("g", { key: p.id, ref: setPieceRef(p.id), transform: `translate(${translateX}, ${translateY})`, style: { cursor: locked ? "default" : clickMode ? "pointer" : "grab" }, pointerEvents: clickMode && isDragging ? "none" : "auto" }, ("shape" in bp ? bp.shape : []).map((poly, idx) => {
975
+ const showBorders = shouldShowBorders();
976
+ shouldUseSelectiveBorders(p.blueprintId);
977
+ return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
978
+ "path",
979
+ {
980
+ d: pathD(poly),
981
+ fill: isConnectivityLocked ? CONFIG.color.piece.invalidFill : isCarriedInvalid ? CONFIG.color.piece.invalidFill : isDragging ? draggingFillColor : validFillColor,
982
+ 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,
983
+ stroke: "none",
984
+ onPointerDown: (e) => onPiecePointerDown(e, p)
985
+ }
986
+ ), showBorders);
987
+ }), isDragging && isCarriedInvalid && "shape" in bp && bp.shape.length > 0 && (() => {
988
+ const { cx, cy } = polysAABB$1(bp.shape);
989
+ const size = CONFIG.size.invalidMarker.sizePx;
990
+ const strokeWidth = CONFIG.size.invalidMarker.strokePx;
991
+ const borderWidth = strokeWidth + 2;
992
+ return /* @__PURE__ */ React.createElement("g", { key: "invalid-marker" }, /* @__PURE__ */ React.createElement(
993
+ "line",
994
+ {
995
+ x1: cx - size,
996
+ y1: cy - size,
997
+ x2: cx + size,
998
+ y2: cy + size,
999
+ stroke: "white",
1000
+ strokeWidth: borderWidth,
1001
+ strokeLinecap: "round"
1002
+ }
1003
+ ), /* @__PURE__ */ React.createElement(
1004
+ "line",
1005
+ {
1006
+ x1: cx - size,
1007
+ y1: cy - size,
1008
+ x2: cx + size,
1009
+ y2: cy + size,
1010
+ stroke: CONFIG.color.piece.invalidStroke,
1011
+ strokeWidth,
1012
+ strokeLinecap: "round"
1013
+ }
1014
+ ), /* @__PURE__ */ React.createElement(
1015
+ "line",
1016
+ {
1017
+ x1: cx + size,
1018
+ y1: cy - size,
1019
+ x2: cx - size,
1020
+ y2: cy + size,
1021
+ stroke: "white",
1022
+ strokeWidth: borderWidth,
1023
+ strokeLinecap: "round"
1024
+ }
1025
+ ), /* @__PURE__ */ React.createElement(
1026
+ "line",
1027
+ {
1028
+ x1: cx + size,
1029
+ y1: cy - size,
1030
+ x2: cx - size,
1031
+ y2: cy + size,
1032
+ stroke: CONFIG.color.piece.invalidStroke,
1033
+ strokeWidth,
1034
+ strokeLinecap: "round"
1035
+ }
1036
+ ));
1037
+ })());
1038
+ });
1039
+ };
862
1040
  const centerView = controller.state.blueprintView;
863
1041
  const bps = centerView === "primitives" ? controller.state.primitives : controller.state.quickstash;
864
1042
  const QS_SLOTS = controller.state.cfg.maxQuickstashSlots;
@@ -879,6 +1057,12 @@ function BoardView(props) {
879
1057
  const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
880
1058
  const cx = bb.min.x + bb.width / 2;
881
1059
  const cy = bb.min.y + bb.height / 2;
1060
+ const fillColor = getPieceColor(
1061
+ bp,
1062
+ usePrimitiveColorsBlueprints || false,
1063
+ CONFIG.color.blueprint.fill,
1064
+ primitiveColorIndices || [0, 1, 2, 3, 4]
1065
+ );
882
1066
  return /* @__PURE__ */ React.createElement(
883
1067
  "g",
884
1068
  {
@@ -890,7 +1074,7 @@ function BoardView(props) {
890
1074
  {
891
1075
  key: idx,
892
1076
  d: pathD(poly),
893
- fill: CONFIG.color.blueprint.fill,
1077
+ fill: fillColor,
894
1078
  opacity: CONFIG.opacity.blueprint,
895
1079
  stroke: "none",
896
1080
  strokeWidth: 0,
@@ -914,7 +1098,7 @@ function BoardView(props) {
914
1098
  onPointerDown: (e) => {
915
1099
  onRootPointerDown(e);
916
1100
  },
917
- style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1101
+ style: { background: CONFIG.color.background, touchAction: "none", userSelect: "none" }
918
1102
  },
919
1103
  layout.sectors.map((s, i) => {
920
1104
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -924,35 +1108,7 @@ function BoardView(props) {
924
1108
  const work = layout.bands.workspace;
925
1109
  return /* @__PURE__ */ React.createElement("g", { key: `bands-${s.id}` }, controller.state.cfg.target === "workspace" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("path", { d: wedgePath(layout.cx, layout.cy, sil[0], sil[1], s.start, s.end), fill: baseSil, stroke: CONFIG.color.bands.silhouette.stroke, strokeWidth: CONFIG.size.stroke.bandPx, pointerEvents: "none" }), /* @__PURE__ */ React.createElement("path", { d: wedgePath(layout.cx, layout.cy, work[0], work[1], s.start, s.end), fill: done ? CONFIG.color.completion.fill : baseWork, stroke: done ? CONFIG.color.completion.stroke : CONFIG.color.bands.workspace.stroke, strokeWidth: CONFIG.size.stroke.bandPx, pointerEvents: "none" })) : /* @__PURE__ */ React.createElement("path", { d: wedgePath(layout.cx, layout.cy, sil[0], sil[1], s.start, s.end), fill: done ? CONFIG.color.completion.fill : baseSil, stroke: done ? CONFIG.color.completion.stroke : CONFIG.color.bands.silhouette.stroke, strokeWidth: CONFIG.size.stroke.bandPx, pointerEvents: "none" }));
926
1110
  }),
927
- pieces.sort((a, b) => {
928
- if (draggingId === a.id) return 1;
929
- if (draggingId === b.id) return -1;
930
- return 0;
931
- }).map((p) => {
932
- const bp = controller.getBlueprint(p.blueprintId);
933
- const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
934
- const isDragging = draggingId === p.id;
935
- const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
936
- const isConnectivityLocked = lockedPieceId === p.id;
937
- selectedPieceId === p.id;
938
- const isCarriedInvalid = isDragging && dragInvalid;
939
- const translateX = p.x - bb.min.x;
940
- const translateY = p.y - bb.min.y;
941
- return /* @__PURE__ */ React.createElement("g", { key: p.id, ref: setPieceRef(p.id), transform: `translate(${translateX}, ${translateY})`, style: { cursor: locked ? "default" : clickMode ? "pointer" : "grab" }, pointerEvents: clickMode && isDragging ? "none" : "auto" }, ("shape" in bp ? bp.shape : []).map((poly, idx) => {
942
- const showBorders = shouldShowBorders();
943
- shouldUseSelectiveBorders(p.blueprintId);
944
- return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
945
- "path",
946
- {
947
- d: pathD(poly),
948
- fill: isConnectivityLocked ? CONFIG.color.piece.invalidFill : isCarriedInvalid ? CONFIG.color.piece.invalidFill : isDragging ? CONFIG.color.piece.draggingFill : CONFIG.color.piece.validFill,
949
- 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,
950
- stroke: "none",
951
- onPointerDown: (e) => onPiecePointerDown(e, p)
952
- }
953
- ), showBorders);
954
- }));
955
- }),
1111
+ /* @__PURE__ */ React.createElement(React.Fragment, null, renderSilhouettes(), renderPieces()) ,
956
1112
  layout.sectors.map((s) => {
957
1113
  const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
958
1114
  if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
@@ -960,23 +1116,32 @@ function BoardView(props) {
960
1116
  const rect = rectForBand(layout, s, "silhouette", 1);
961
1117
  const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
962
1118
  const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
963
- return /* @__PURE__ */ React.createElement("g", { key: `sil-decomposed-${s.id}`, pointerEvents: "none" }, placedPolys.map((scaledPoly, i) => /* @__PURE__ */ React.createElement(React.Fragment, { key: `prim-${i}` }, /* @__PURE__ */ React.createElement(
964
- "path",
965
- {
966
- d: pathD(scaledPoly),
967
- fill: CONFIG.color.silhouetteMask,
968
- opacity: CONFIG.opacity.silhouetteMask,
969
- stroke: "none"
970
- }
971
- ), /* @__PURE__ */ React.createElement(
972
- "path",
973
- {
974
- d: pathD(scaledPoly),
975
- fill: "none",
976
- stroke: CONFIG.color.tangramDecomposition.stroke,
977
- strokeWidth: CONFIG.size.stroke.tangramDecompositionPx
1119
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-decomposed-${s.id}`, pointerEvents: "none" }, placedPolys.map((scaledPoly, i) => {
1120
+ const primInfo = primitiveDecomposition[i];
1121
+ if (usePrimitiveColorsTargets && primInfo?.kind && primitiveColorIndices) {
1122
+ const kindToIndex = {
1123
+ "square": 0,
1124
+ "smalltriangle": 1,
1125
+ "parallelogram": 2,
1126
+ "medtriangle": 3,
1127
+ "largetriangle": 4
1128
+ };
1129
+ const primitiveIndex = kindToIndex[primInfo.kind];
1130
+ if (primitiveIndex !== void 0 && primitiveColorIndices[primitiveIndex] !== void 0) {
1131
+ primitiveColorIndices[primitiveIndex];
1132
+ }
978
1133
  }
979
- ))));
1134
+ return /* @__PURE__ */ React.createElement(
1135
+ "path",
1136
+ {
1137
+ key: `prim-fill-${i}`,
1138
+ d: pathD(scaledPoly),
1139
+ fill: "none",
1140
+ opacity: 0,
1141
+ stroke: "none"
1142
+ }
1143
+ );
1144
+ }));
980
1145
  } else {
981
1146
  const placedPolys = placedSilBySector.get(s.id) ?? [];
982
1147
  if (!placedPolys.length) return null;
@@ -1067,7 +1232,25 @@ function BoardView(props) {
1067
1232
  const by = layout.cy + blueprintRingR * Math.sin(theta);
1068
1233
  return renderBlueprintGlyph(bp, bx, by);
1069
1234
  }),
1070
- controller.state.endedAt && /* @__PURE__ */ React.createElement("g", { pointerEvents: "none" }, /* @__PURE__ */ React.createElement("circle", { cx: layout.cx, cy: layout.cy, r: layout.outerR - 3, fill: "none", stroke: CONFIG.color.piece.allGreenStroke, strokeWidth: CONFIG.size.stroke.allGreenStrokePx, opacity: 0 }, /* @__PURE__ */ React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "160ms", fill: "freeze" }), /* @__PURE__ */ React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", begin: "560ms", dur: "520ms", fill: "freeze" })))
1235
+ controller.state.endedAt && /* @__PURE__ */ React.createElement("g", { pointerEvents: "none" }, /* @__PURE__ */ React.createElement("circle", { cx: layout.cx, cy: layout.cy, r: layout.outerR - 3, fill: "none", stroke: CONFIG.color.piece.allGreenStroke, strokeWidth: CONFIG.size.stroke.allGreenStrokePx, opacity: 0 }, /* @__PURE__ */ React.createElement("animate", { attributeName: "opacity", from: "0", to: "1", dur: "160ms", fill: "freeze" }), /* @__PURE__ */ React.createElement("animate", { attributeName: "opacity", from: "1", to: "0", begin: "560ms", dur: "520ms", fill: "freeze" }))),
1236
+ showTangramDecomposition && layout.sectors.map((s) => {
1237
+ const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
1238
+ if (!sectorCfg?.silhouette.primitiveDecomposition) return null;
1239
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
1240
+ const rect = rectForBand(layout, s, "silhouette", 1);
1241
+ const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
1242
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
1243
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-borders-${s.id}`, pointerEvents: "none" }, placedPolys.map((scaledPoly, i) => /* @__PURE__ */ React.createElement(
1244
+ "path",
1245
+ {
1246
+ key: `prim-border-${i}`,
1247
+ d: pathD(scaledPoly),
1248
+ fill: "none",
1249
+ stroke: CONFIG.color.tangramDecomposition.stroke,
1250
+ strokeWidth: CONFIG.size.stroke.tangramDecompositionPx
1251
+ }
1252
+ )));
1253
+ })
1071
1254
  );
1072
1255
  }
1073
1256
 
@@ -2952,7 +3135,10 @@ function GameBoard(props) {
2952
3135
  onPieceRemove,
2953
3136
  onInteraction,
2954
3137
  onTrialEnd,
2955
- onControllerReady
3138
+ onControllerReady,
3139
+ usePrimitiveColorsBlueprints,
3140
+ usePrimitiveColorsTargets,
3141
+ primitiveColorIndices
2956
3142
  } = props;
2957
3143
  const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
2958
3144
  const controller = React.useMemo(() => {
@@ -3276,7 +3462,7 @@ function GameBoard(props) {
3276
3462
  justifyContent: "center",
3277
3463
  alignItems: "center",
3278
3464
  padding: "20px",
3279
- background: "#f5f5f5",
3465
+ background: CONFIG.color.background,
3280
3466
  flex: "0 0 auto"
3281
3467
  };
3282
3468
  const headerContentStyle = {
@@ -3292,14 +3478,20 @@ function GameBoard(props) {
3292
3478
  lineHeight: 1.5,
3293
3479
  textAlign: "center"
3294
3480
  };
3481
+ const scaleX = svgDimensions.width / viewBox.w;
3482
+ const rightEdgeOfSemicircleLogical = viewBox.w / 2 + layout.outerR;
3483
+ const distanceFromRightEdgeLogical = viewBox.w - rightEdgeOfSemicircleLogical;
3484
+ const distanceFromRightEdgePx = distanceFromRightEdgeLogical * scaleX;
3485
+ const charWidth = 24 * 0.6;
3486
+ const offsetLeft = charWidth * 5;
3295
3487
  const timerStyle = {
3296
3488
  position: "absolute",
3297
- right: 0,
3489
+ right: `${distanceFromRightEdgePx + offsetLeft}px`,
3298
3490
  fontSize: "24px",
3299
3491
  fontWeight: "bold",
3300
3492
  fontFamily: "monospace",
3301
3493
  color: "#333",
3302
- minWidth: "80px",
3494
+ whiteSpace: "nowrap",
3303
3495
  textAlign: "right"
3304
3496
  };
3305
3497
  const gameboardWrapperStyle = {
@@ -3344,6 +3536,9 @@ function GameBoard(props) {
3344
3536
  onCenterBadgePointerDown,
3345
3537
  showTangramDecomposition: showTangramDecomposition ?? false,
3346
3538
  scaleS,
3539
+ usePrimitiveColorsBlueprints: usePrimitiveColorsBlueprints ?? false,
3540
+ usePrimitiveColorsTargets: usePrimitiveColorsTargets ?? false,
3541
+ primitiveColorIndices: primitiveColorIndices ?? [0, 1, 2, 3, 4],
3347
3542
  ...eventCallbacks
3348
3543
  }
3349
3544
  ))));
@@ -3510,7 +3705,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3510
3705
  quickstashMacros,
3511
3706
  primitiveOrder,
3512
3707
  onInteraction,
3513
- onTrialEnd
3708
+ onTrialEnd,
3709
+ usePrimitiveColorsBlueprints,
3710
+ primitiveColorIndices
3514
3711
  } = params;
3515
3712
  const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
3516
3713
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
@@ -3602,7 +3799,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3602
3799
  ...params.instructions && { instructions: params.instructions },
3603
3800
  onControllerReady: handleControllerReady,
3604
3801
  ...onInteraction && { onInteraction },
3605
- ...onTrialEnd && { onTrialEnd }
3802
+ ...onTrialEnd && { onTrialEnd },
3803
+ ...usePrimitiveColorsBlueprints !== void 0 && { usePrimitiveColorsBlueprints },
3804
+ ...primitiveColorIndices !== void 0 && { primitiveColorIndices }
3606
3805
  }));
3607
3806
  return { root, display_element, jsPsych };
3608
3807
  }
@@ -3668,6 +3867,18 @@ const info = {
3668
3867
  onTrialEnd: {
3669
3868
  type: ParameterType.FUNCTION,
3670
3869
  default: void 0
3870
+ },
3871
+ /** Whether to use distinct colors for each primitive shape type in blueprints */
3872
+ use_primitive_colors_blueprints: {
3873
+ type: ParameterType.BOOL,
3874
+ default: false,
3875
+ description: "Whether each primitive shape type should have its own distinct color in the blueprint dock area"
3876
+ },
3877
+ /** Indices mapping primitives to colors from the color palette */
3878
+ primitive_color_indices: {
3879
+ type: ParameterType.OBJECT,
3880
+ default: [0, 1, 2, 3, 4],
3881
+ description: "Array of 5 integers indexing into primitiveColors array, mapping [square, smalltriangle, parallelogram, medtriangle, largetriangle] to colors"
3671
3882
  }
3672
3883
  },
3673
3884
  data: {
@@ -3712,7 +3923,9 @@ class TangramPrepPlugin {
3712
3923
  primitiveOrder: trial.primitive_order,
3713
3924
  instructions: trial.instructions,
3714
3925
  onInteraction: trial.onInteraction,
3715
- onTrialEnd: wrappedOnTrialEnd
3926
+ onTrialEnd: wrappedOnTrialEnd,
3927
+ usePrimitiveColorsBlueprints: trial.use_primitive_colors_blueprints,
3928
+ primitiveColorIndices: trial.primitive_color_indices
3716
3929
  };
3717
3930
  const { root, display_element: element, jsPsych } = startPrepTrial(display_element, params, this.jsPsych);
3718
3931
  element.__reactContext = { root, jsPsych };