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/dist/construct/index.browser.js +41 -2
- package/dist/construct/index.browser.js.map +1 -1
- package/dist/construct/index.browser.min.js +14 -11
- package/dist/construct/index.browser.min.js.map +1 -1
- package/dist/construct/index.cjs +41 -2
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.js +41 -2
- package/dist/construct/index.js.map +1 -1
- package/dist/index.cjs +41 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +41 -2
- package/dist/index.js.map +1 -1
- package/dist/prep/index.browser.js +41 -2
- package/dist/prep/index.browser.js.map +1 -1
- package/dist/prep/index.browser.min.js +16 -13
- package/dist/prep/index.browser.min.js.map +1 -1
- package/dist/prep/index.cjs +41 -2
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.js +41 -2
- package/dist/prep/index.js.map +1 -1
- package/package.json +1 -1
- package/src/assets/README.md +6 -0
- package/src/assets/images.d.ts +19 -0
- package/src/assets/locked.png +0 -0
- package/src/assets/unlocked.png +0 -0
- package/src/core/components/board/BoardView.tsx +72 -29
- package/src/core/io/InteractionTracker.ts +16 -8
- package/src/core/io/data-tracking.ts +3 -0
- package/tangram-construct.min.js +14 -11
- package/tangram-prep.min.js +16 -13
package/package.json
CHANGED
|
@@ -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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|