jspsych-tangram 0.0.8 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych-tangram",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -0,0 +1,6 @@
1
+ # Assets Directory
2
+
3
+ - `locked.png` - Icon displayed when quickstash view is active (locked to quickstash only)
4
+ - `unlocked.png` - Icon displayed when primitives view is active (unlocked/full inventory)
5
+
6
+ These icons appear in the center badge button that switches between blueprint views.
@@ -0,0 +1,19 @@
1
+ declare module "*.png" {
2
+ const value: string;
3
+ export default value;
4
+ }
5
+
6
+ declare module "*.jpg" {
7
+ const value: string;
8
+ export default value;
9
+ }
10
+
11
+ declare module "*.jpeg" {
12
+ const value: string;
13
+ export default value;
14
+ }
15
+
16
+ declare module "*.svg" {
17
+ const value: string;
18
+ export default value;
19
+ }
Binary file
Binary file
@@ -5,6 +5,10 @@ import { boundsOfBlueprint, placeSilhouetteGridAlignedAsPolys } from "@/core/eng
5
5
  import { CONFIG } from "@/core/config/config";
6
6
  import { generateEdgeStrokePaths, shouldUseSelectiveBorders, shouldShowBorders, getHiddenEdgesForPolygon } from "@/core/engine/validation/border-rendering";
7
7
 
8
+ // Import lock/unlock icons
9
+ import lockedIcon from "@/assets/locked.png";
10
+ import unlockedIcon from "@/assets/unlocked.png";
11
+
8
12
  export type PieceView = { id: string; blueprintId: string; x: number; y: number; sectorId?: string };
9
13
  export type AnchorDots = { sectorId: string; valid: {x:number;y:number}[]; invalid: {x:number;y:number}[] };
10
14
 
@@ -89,12 +93,12 @@ export default function BoardView(props: BoardViewProps) {
89
93
  : CONFIG.layout.constraints.quickstashDiamAnchors;
90
94
  const D_slot = anchorsDiameterToPx(reqAnchors);
91
95
  const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
92
-
96
+
93
97
  // Minimum radius to avoid overlapping with badge
94
98
  // Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
95
99
  const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
96
100
  const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
97
-
101
+
98
102
  // Clamp to [R_min, ringMax]
99
103
  const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
100
104
 
@@ -195,7 +199,7 @@ export default function BoardView(props: BoardViewProps) {
195
199
  stroke="none"
196
200
  onPointerDown={(e) => onPiecePointerDown(e, p)}
197
201
  />
198
-
202
+
199
203
  {/* Border paths - only rendered if borders are enabled */}
200
204
  {showBorders && (
201
205
  useSelectiveBorders ? (
@@ -205,32 +209,32 @@ export default function BoardView(props: BoardViewProps) {
205
209
  const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
206
210
  const allPiecesAsPieces = allPiecesInSector.map(piece => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
207
211
  const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
208
-
212
+
209
213
  // Check if this piece's edges were hidden due to touching the dragged piece
210
214
  const draggedPiece = draggingId ? allPiecesInSector.find(piece => piece.id === draggingId) : null;
211
-
215
+
212
216
  // For composites, we need to distinguish between internal edges (always hidden) and external edges (shown when dragging)
213
217
  let wasTouchingDraggedPiece: boolean[];
214
218
  if (p.blueprintId.startsWith('comp:')) {
215
219
  // For composites, check internal edges separately from external edges
216
220
  const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
217
- const externalHiddenEdges = draggedPiece ?
218
- getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
221
+ const externalHiddenEdges = draggedPiece ?
222
+ getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
219
223
  new Array(hiddenEdges.length).fill(false);
220
-
224
+
221
225
  // Only consider external edges as "touching dragged piece"
222
226
  wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
223
227
  } else {
224
228
  // For primitives, all hidden edges are external
225
- wasTouchingDraggedPiece = draggedPiece ?
226
- getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
229
+ wasTouchingDraggedPiece = draggedPiece ?
230
+ getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
227
231
  new Array(hiddenEdges.length).fill(false);
228
232
  }
229
-
233
+
230
234
  return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
231
235
  // Show border if: 1) it wasn't hidden originally, OR 2) it was hidden due to touching the dragged piece
232
236
  const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
233
-
237
+
234
238
  // Special case: if this piece is being dragged and it's a composite, keep internal edges hidden
235
239
  let isHidden: boolean;
236
240
  if (isDragging && p.blueprintId.startsWith('comp:')) {
@@ -241,7 +245,7 @@ export default function BoardView(props: BoardViewProps) {
241
245
  // Normal logic for non-dragged pieces or primitives
242
246
  isHidden = isDragging ? false : ((hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece);
243
247
  }
244
-
248
+
245
249
  return (
246
250
  <path
247
251
  key={`stroke-${idx}-${strokeIdx}`}
@@ -275,16 +279,16 @@ export default function BoardView(props: BoardViewProps) {
275
279
  {/* silhouettes (use pre-placed polys) */}
276
280
  {layout.sectors.map((s) => {
277
281
  const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
278
-
282
+
279
283
  // Check if we should render decomposed primitives
280
284
  if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
281
285
  const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
282
-
286
+
283
287
  // Use same positioning logic as unified silhouette
284
288
  const rect = rectForBand(layout, s, "silhouette", 1.0);
285
289
  const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
286
290
  const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
287
-
291
+
288
292
  return (
289
293
  <g key={`sil-decomposed-${s.id}`} pointerEvents="none">
290
294
  {placedPolys.map((scaledPoly, i) => (
@@ -296,7 +300,7 @@ export default function BoardView(props: BoardViewProps) {
296
300
  opacity={CONFIG.opacity.silhouetteMask}
297
301
  stroke="none"
298
302
  />
299
-
303
+
300
304
  {/* Full perimeter border */}
301
305
  <path
302
306
  d={pathD(scaledPoly)}
@@ -344,26 +348,65 @@ export default function BoardView(props: BoardViewProps) {
344
348
  );
345
349
  })}
346
350
 
351
+ {/* SVG filter for inverting icon colors to white */}
352
+ <defs>
353
+ <filter id="invert-to-white">
354
+ <feColorMatrix
355
+ type="matrix"
356
+ values="-1 0 0 0 1
357
+ 0 -1 0 0 1
358
+ 0 0 -1 0 1
359
+ 0 0 0 1 0"
360
+ />
361
+ </filter>
362
+ </defs>
363
+
347
364
  {/* center badge */}
348
365
  {(() => {
349
366
  const isPrep = controller.state.cfg.mode === "prep";
350
367
  const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
351
368
  const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
352
-
369
+ const [imageError, setImageError] = React.useState(false);
370
+
371
+ // Size the image to fill most of the badge (80% of diameter)
372
+ const iconSize = badgeR * 1.6;
373
+ const iconOffset = iconSize / 2;
374
+
353
375
  return (
354
- <g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
355
- style={{ cursor: isClickable ? "pointer" : "default" }}
376
+ <g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
377
+ style={{ cursor: isClickable ? "pointer" : "default" }}
356
378
  onPointerDown={isClickable ? onCenterBadgePointerDown : undefined}>
357
- <circle r={badgeR}
358
- fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
379
+ <circle r={badgeR}
380
+ fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
359
381
  opacity={isSubmitEnabled ? 1.0 : 0.5} />
360
- <text textAnchor="middle"
361
- dominantBaseline="middle"
362
- fontSize={CONFIG.size.badgeFontPx}
363
- fill={isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888"}
364
- pointerEvents="none">
365
- {isPrep ? "Submit" : controller.state.blueprintView}
366
- </text>
382
+ {isPrep ? (
383
+ <text textAnchor="middle"
384
+ dominantBaseline="middle"
385
+ fontSize={CONFIG.size.badgeFontPx}
386
+ fill={isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888"}
387
+ pointerEvents="none">
388
+ Submit
389
+ </text>
390
+ ) : imageError ? (
391
+ <text textAnchor="middle"
392
+ dominantBaseline="middle"
393
+ fontSize={CONFIG.size.badgeFontPx}
394
+ fill={CONFIG.color.blueprint.labelFill}
395
+ pointerEvents="none">
396
+ inventory
397
+ </text>
398
+ ) : (
399
+ <image
400
+ href={controller.state.blueprintView === "quickstash" ? lockedIcon : unlockedIcon}
401
+ x={-iconOffset}
402
+ y={-iconOffset}
403
+ width={iconSize}
404
+ height={iconSize}
405
+ pointerEvents="none"
406
+ onError={() => setImageError(true)}
407
+ filter="url(#invert-to-white)"
408
+ />
409
+ )}
367
410
  </g>
368
411
  );
369
412
  })()}
@@ -351,11 +351,17 @@ export class InteractionTracker {
351
351
  const totalDuration = trialEndTime - this.trialStartTime;
352
352
  const finalSnapshot = this.buildStateSnapshot();
353
353
 
354
+ // Calculate anchor to stimuli ratio (grid step in stimuli units)
355
+ // CONFIG.layout.grid.stepPx is the pixel size of one anchor unit
356
+ // CONFIG.layout.grid.unitPx is the pixel size of one stimuli unit (UNIT in tangram.py)
357
+ // So the ratio is stepPx / unitPx (typically 20 / 40 = 0.5)
358
+ const anchorToStimuliRatio = CONFIG.layout.grid.stepPx / CONFIG.layout.grid.unitPx;
359
+
354
360
  const mode = this.controller.state.cfg.mode;
355
361
 
356
362
  if (mode === 'construction') {
357
363
  const finalBlueprintState = this.buildFinalBlueprintState(finalSnapshot);
358
-
364
+
359
365
  // Extract composites as quickstash macros (ready for next trial input)
360
366
  const quickstashMacros = finalBlueprintState
361
367
  .filter(bp => bp.blueprintType === 'composite')
@@ -370,6 +376,7 @@ export class InteractionTracker {
370
376
  trialStartTime: this.trialStartTime,
371
377
  trialEndTime,
372
378
  totalDuration,
379
+ anchorToStimuliRatio,
373
380
  trialParams: this.trialParams,
374
381
  endReason: endReason as 'timeout' | 'auto_complete',
375
382
  completionTimes: this.completionTimes,
@@ -399,6 +406,7 @@ export class InteractionTracker {
399
406
  trialStartTime: this.trialStartTime,
400
407
  trialEndTime,
401
408
  totalDuration,
409
+ anchorToStimuliRatio,
402
410
  trialParams: this.trialParams,
403
411
  endReason: 'submit',
404
412
  createdMacros: finalMacros,
@@ -449,7 +457,7 @@ export class InteractionTracker {
449
457
  const shapeOriginPositions = sec.pieces.map(piece => {
450
458
  const bp = this.controller.getBlueprint(piece.blueprintId);
451
459
  const kind = bp && 'kind' in bp ? bp.kind : piece.blueprintId;
452
-
460
+
453
461
  // Get the piece's actual position in pixels from controller state
454
462
  const actualPiece = this.controller.findPiece(piece.pieceId);
455
463
  if (!actualPiece || !bp) {
@@ -458,10 +466,10 @@ export class InteractionTracker {
458
466
  position: piece.position // Fallback to bbox position
459
467
  };
460
468
  }
461
-
469
+
462
470
  // Get bounding box of the blueprint using the geometry helper
463
471
  const bb = boundsOfBlueprint(bp, this.controller.getPrimitive);
464
-
472
+
465
473
  // Convert bbox position to shape origin position
466
474
  // In pixels: shapeOriginPos = piece.pos - bb.min
467
475
  // Then convert to anchor coordinates
@@ -470,7 +478,7 @@ export class InteractionTracker {
470
478
  y: actualPiece.pos.y - bb.min.y
471
479
  };
472
480
  const shapeOriginAnchor = this.toAnchorPoint(shapeOriginPixels);
473
-
481
+
474
482
  return {
475
483
  kind,
476
484
  position: shapeOriginAnchor // Shape origin in anchor coordinates
@@ -666,7 +674,7 @@ export class InteractionTracker {
666
674
  // Convert to array format with blueprint definitions
667
675
  return Array.from(counts.entries()).map(([blueprintId, data]) => {
668
676
  const blueprint = this.controller.getBlueprint(blueprintId);
669
-
677
+
670
678
  const result: {
671
679
  blueprintId: string;
672
680
  blueprintType: 'primitive' | 'composite';
@@ -699,7 +707,7 @@ export class InteractionTracker {
699
707
 
700
708
  // Convert pixel-based shape to anchor-based shape
701
709
  if (blueprint.shape) {
702
- result.shape = blueprint.shape.map(poly =>
710
+ result.shape = blueprint.shape.map(poly =>
703
711
  poly.map(vertex => ({
704
712
  x: Math.round(vertex.x / this.gridStep),
705
713
  y: Math.round(vertex.y / this.gridStep)
@@ -714,7 +722,7 @@ export class InteractionTracker {
714
722
  } else if (data.blueprintType === 'primitive' && 'kind' in blueprint) {
715
723
  // Primitive blueprint: convert pixel-based shape to anchor-based shape
716
724
  if (blueprint.shape) {
717
- result.shape = blueprint.shape.map(poly =>
725
+ result.shape = blueprint.shape.map(poly =>
718
726
  poly.map(vertex => ({
719
727
  x: Math.round(vertex.x / this.gridStep),
720
728
  y: Math.round(vertex.y / this.gridStep)
@@ -121,6 +121,9 @@ export interface BaseTrialData {
121
121
  trialEndTime: number; // Absolute timestamp (Date.now())
122
122
  totalDuration: number; // duration in ms (trialEndTime - trialStartTime)
123
123
 
124
+ // Coordinate system metadata
125
+ anchorToStimuliRatio: number; // Ratio of anchor units to stimuli units (gridStepPx / unitPx)
126
+
124
127
  // Parameters passed to the trial
125
128
  trialParams: any; // Cleaned jsPsych plugin parameters (excluding callbacks)
126
129