jspsych-tangram 0.0.16 → 0.0.18
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 +23 -5
- package/dist/construct/index.browser.js.map +1 -1
- package/dist/construct/index.browser.min.js +8 -8
- package/dist/construct/index.browser.min.js.map +1 -1
- package/dist/construct/index.cjs +23 -5
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.js +23 -5
- package/dist/construct/index.js.map +1 -1
- package/dist/grid/index.browser.js +17855 -0
- package/dist/grid/index.browser.js.map +1 -0
- package/dist/grid/index.browser.min.js +47 -0
- package/dist/grid/index.browser.min.js.map +1 -0
- package/dist/grid/index.cjs +547 -0
- package/dist/grid/index.cjs.map +1 -0
- package/dist/grid/index.d.ts +174 -0
- package/dist/grid/index.js +545 -0
- package/dist/grid/index.js.map +1 -0
- package/dist/index.cjs +548 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +187 -16
- package/dist/index.js +549 -15
- package/dist/index.js.map +1 -1
- package/dist/prep/index.browser.js +23 -5
- package/dist/prep/index.browser.js.map +1 -1
- package/dist/prep/index.browser.min.js +6 -6
- package/dist/prep/index.browser.min.js.map +1 -1
- package/dist/prep/index.cjs +23 -5
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.js +23 -5
- package/dist/prep/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/components/board/GameBoard.tsx +12 -0
- package/src/core/io/InteractionTracker.ts +19 -7
- package/src/core/io/data-tracking.ts +5 -0
- package/src/index.ts +2 -1
- package/src/plugins/tangram-grid/GridApp.tsx +522 -0
- package/src/plugins/tangram-grid/index.ts +154 -0
- package/tangram-construct.min.js +8 -8
- package/tangram-prep.min.js +6 -6
package/package.json
CHANGED
|
@@ -378,6 +378,18 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
378
378
|
};
|
|
379
379
|
}, [sectors, layoutMode, target, maxQuickstashSlots, primitives.length]);
|
|
380
380
|
|
|
381
|
+
// Update tracker with sector centers whenever layout changes
|
|
382
|
+
React.useEffect(() => {
|
|
383
|
+
if (tracker && layout) {
|
|
384
|
+
const centers: { [sectorId: string]: { x: number; y: number } } = {};
|
|
385
|
+
layout.sectors.forEach(s => {
|
|
386
|
+
const rect = rectForBand(layout, s, "silhouette", 1.0);
|
|
387
|
+
centers[s.id] = { x: rect.cx, y: rect.cy };
|
|
388
|
+
});
|
|
389
|
+
tracker.setSectorCenters(centers);
|
|
390
|
+
}
|
|
391
|
+
}, [tracker, layout]);
|
|
392
|
+
|
|
381
393
|
// Force re-render utility
|
|
382
394
|
const [, force] = React.useReducer((x) => x + 1, 0);
|
|
383
395
|
|
|
@@ -99,7 +99,10 @@ export class InteractionTracker {
|
|
|
99
99
|
|
|
100
100
|
// Trial timing
|
|
101
101
|
private trialStartTime: number;
|
|
102
|
-
private
|
|
102
|
+
private completionTimes: Array<{ sectorId: string; completedAt: number }> = [];
|
|
103
|
+
|
|
104
|
+
// Sector centers (set by GameBoard after layout computation)
|
|
105
|
+
private sectorCenters: { [sectorId: string]: { x: number; y: number } } = {};
|
|
103
106
|
|
|
104
107
|
// Interaction state
|
|
105
108
|
private interactionIndex: number = 0;
|
|
@@ -113,21 +116,22 @@ export class InteractionTracker {
|
|
|
113
116
|
// Interaction history (for TrialEndData)
|
|
114
117
|
private interactions: InteractionEvent[] = [];
|
|
115
118
|
|
|
116
|
-
// Construction-specific tracking
|
|
117
|
-
private completionTimes: Array<{ sectorId: string; completedAt: number }> = [];
|
|
118
|
-
|
|
119
119
|
// Prep-specific tracking
|
|
120
120
|
private createdMacros: MacroSnapshot[] = [];
|
|
121
121
|
|
|
122
|
+
// Grid step for coordinate conversion
|
|
123
|
+
private gridStep: number;
|
|
124
|
+
|
|
122
125
|
constructor(
|
|
123
126
|
controller: BaseGameController,
|
|
124
127
|
callbacks: DataTrackingCallbacks,
|
|
125
|
-
trialParams
|
|
128
|
+
trialParams: any
|
|
126
129
|
) {
|
|
127
130
|
this.controller = controller;
|
|
128
131
|
this.callbacks = callbacks;
|
|
129
132
|
this.trialParams = trialParams;
|
|
130
133
|
this.trialStartTime = Date.now();
|
|
134
|
+
this.gridStep = CONFIG.layout.grid.stepPx;
|
|
131
135
|
|
|
132
136
|
// Register tracking callbacks with controller
|
|
133
137
|
this.controller.setTrackingCallbacks({
|
|
@@ -325,7 +329,14 @@ export class InteractionTracker {
|
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
/**
|
|
328
|
-
*
|
|
332
|
+
* Set sector centers (for anchor alignment)
|
|
333
|
+
*/
|
|
334
|
+
setSectorCenters(centers: { [sectorId: string]: { x: number; y: number } }): void {
|
|
335
|
+
this.sectorCenters = centers;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Record a sector completion event
|
|
329
340
|
*/
|
|
330
341
|
recordSectorCompletion(sectorId: string): void {
|
|
331
342
|
this.completionTimes.push({
|
|
@@ -557,7 +568,8 @@ export class InteractionTracker {
|
|
|
557
568
|
sectorId: sector.id,
|
|
558
569
|
completed,
|
|
559
570
|
pieceCount: pieces.length,
|
|
560
|
-
pieces
|
|
571
|
+
pieces,
|
|
572
|
+
center: this.sectorCenters[sector.id] ? this.toAnchorPoint(this.sectorCenters[sector.id]) : undefined
|
|
561
573
|
};
|
|
562
574
|
|
|
563
575
|
if (sectorState?.completedAt !== undefined) {
|
|
@@ -146,6 +146,10 @@ export interface ConstructionTrialData extends BaseTrialData {
|
|
|
146
146
|
completedAt: number; // timestamp in ms (Date.now())
|
|
147
147
|
}>;
|
|
148
148
|
|
|
149
|
+
// Sector centers (computed layout positions)
|
|
150
|
+
// Removed in favor of SectorSnapshot.center
|
|
151
|
+
// sectorCenters?: { [sectorId: string]: { x: number; y: number } };
|
|
152
|
+
|
|
149
153
|
// Final blueprint state (usage counts + definitions)
|
|
150
154
|
finalBlueprintState: Array<{
|
|
151
155
|
blueprintId: string;
|
|
@@ -212,6 +216,7 @@ export interface SectorSnapshot {
|
|
|
212
216
|
completedAt?: number; // timestamp in ms (Date.now()) (undefined if not completed)
|
|
213
217
|
pieceCount: number;
|
|
214
218
|
pieces: PieceSnapshot[];
|
|
219
|
+
center?: { x: number; y: number }; // Center of sector in anchor coordinates
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
/**
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as TangramConstructPlugin } from "./plugins/tangram-construct";
|
|
2
2
|
export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
|
|
3
3
|
export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
|
|
4
|
-
export { default as TangramAFCPlugin } from "./plugins/tangram-afc";
|
|
4
|
+
export { default as TangramAFCPlugin } from "./plugins/tangram-afc";
|
|
5
|
+
export { default as TangramGridPlugin } from "./plugins/tangram-grid";
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GridApp.tsx - React wrapper for tangram grid display with text input
|
|
3
|
+
*
|
|
4
|
+
* This component displays a grid of tangrams with a text input field
|
|
5
|
+
* and submit button for collecting free-text responses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useRef, useState, useMemo, useEffect } from "react";
|
|
9
|
+
import { createRoot } from "react-dom/client";
|
|
10
|
+
import { JsPsych } from "jspsych";
|
|
11
|
+
import type { Poly, TanKind } from "../../core/domain/types";
|
|
12
|
+
import { CONFIG } from "../../core/config/config";
|
|
13
|
+
|
|
14
|
+
export interface StartGridTrialParams {
|
|
15
|
+
tangrams: any[];
|
|
16
|
+
n_rows: number;
|
|
17
|
+
n_cols: number;
|
|
18
|
+
prompt_text: string;
|
|
19
|
+
button_text: string;
|
|
20
|
+
show_tangram_decomposition?: boolean;
|
|
21
|
+
usePrimitiveColors?: boolean;
|
|
22
|
+
primitiveColorIndices?: number[];
|
|
23
|
+
onTrialEnd?: (data: any) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Start a grid trial by rendering the GridView component
|
|
28
|
+
*
|
|
29
|
+
* REQUIRES: display_element is a valid HTMLElement
|
|
30
|
+
* MODIFIES: display_element (renders React into it)
|
|
31
|
+
* EFFECTS: Creates a React root and renders GridView with the given params
|
|
32
|
+
*/
|
|
33
|
+
export function startGridTrial(
|
|
34
|
+
display_element: HTMLElement,
|
|
35
|
+
params: StartGridTrialParams,
|
|
36
|
+
_jsPsych: JsPsych
|
|
37
|
+
) {
|
|
38
|
+
const root = createRoot(display_element);
|
|
39
|
+
root.render(React.createElement(GridView, { params }));
|
|
40
|
+
return { root, display_element, jsPsych: _jsPsych };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface GridViewProps {
|
|
44
|
+
params: StartGridTrialParams;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute bounding box for an array of polygons
|
|
49
|
+
*
|
|
50
|
+
* REQUIRES: polys is an array of polygons with {x, y} points
|
|
51
|
+
* MODIFIES: nothing
|
|
52
|
+
* EFFECTS: Returns {minX, minY, maxX, maxY, width, height} of bounding box
|
|
53
|
+
*/
|
|
54
|
+
function computeBounds(polys: Poly[]) {
|
|
55
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
56
|
+
for (const poly of polys) {
|
|
57
|
+
for (const p of poly) {
|
|
58
|
+
minX = Math.min(minX, p.x);
|
|
59
|
+
minY = Math.min(minY, p.y);
|
|
60
|
+
maxX = Math.max(maxX, p.x);
|
|
61
|
+
maxY = Math.max(maxY, p.y);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* GridView - Main React component for the grid trial
|
|
69
|
+
*
|
|
70
|
+
* REQUIRES: params contains valid tangram specs and grid dimensions
|
|
71
|
+
* MODIFIES: nothing
|
|
72
|
+
* EFFECTS: Renders a grid of tangrams, text input, and submit button;
|
|
73
|
+
* calls onTrialEnd with response data when submitted
|
|
74
|
+
*/
|
|
75
|
+
function GridView({ params }: GridViewProps) {
|
|
76
|
+
const {
|
|
77
|
+
tangrams,
|
|
78
|
+
n_rows,
|
|
79
|
+
n_cols,
|
|
80
|
+
prompt_text,
|
|
81
|
+
button_text,
|
|
82
|
+
show_tangram_decomposition = false,
|
|
83
|
+
usePrimitiveColors = false,
|
|
84
|
+
primitiveColorIndices = [0, 1, 2, 3, 4],
|
|
85
|
+
onTrialEnd
|
|
86
|
+
} = params;
|
|
87
|
+
|
|
88
|
+
const trialStartTime = useRef<number>(Date.now());
|
|
89
|
+
const [response, setResponse] = useState<string>("");
|
|
90
|
+
const [cellSize, setCellSize] = useState<number>(100);
|
|
91
|
+
const controlsRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
|
|
93
|
+
// Layout constants
|
|
94
|
+
const GRID_GAP = 6;
|
|
95
|
+
const CONTAINER_PADDING = 8;
|
|
96
|
+
const CELL_MARGIN = 0.05; // 5% margin inside cell for tangram
|
|
97
|
+
// jsPsych progress bar: 20px height + 8px top/bottom padding + 18px margin
|
|
98
|
+
const PROGRESS_BAR_HEIGHT = 58;
|
|
99
|
+
// Border width on each cell (from CONFIG.size.stroke.bandPx)
|
|
100
|
+
const CELL_BORDER = CONFIG.size.stroke.bandPx ?? 1;
|
|
101
|
+
// Extra buffer to prevent scrollbars from appearing
|
|
102
|
+
const SAFETY_BUFFER = 8;
|
|
103
|
+
|
|
104
|
+
// Inject style to override jspsych-content max-width constraint
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const styleId = "tangram-grid-jspsych-override";
|
|
107
|
+
if (!document.getElementById(styleId)) {
|
|
108
|
+
const style = document.createElement("style");
|
|
109
|
+
style.id = styleId;
|
|
110
|
+
style.textContent = `
|
|
111
|
+
.jspsych-content {
|
|
112
|
+
max-width: 100% !important;
|
|
113
|
+
width: 100% !important;
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
document.head.appendChild(style);
|
|
117
|
+
}
|
|
118
|
+
return () => {
|
|
119
|
+
const style = document.getElementById(styleId);
|
|
120
|
+
if (style) style.remove();
|
|
121
|
+
};
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// Canonical piece names
|
|
125
|
+
const CANON = new Set([
|
|
126
|
+
"square",
|
|
127
|
+
"smalltriangle",
|
|
128
|
+
"parallelogram",
|
|
129
|
+
"medtriangle",
|
|
130
|
+
"largetriangle"
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// Convert TangramSpec to internal format with mask and decomposition
|
|
134
|
+
const processedTangrams = useMemo(() => {
|
|
135
|
+
return tangrams.map((tangramSpec) => {
|
|
136
|
+
const filteredTans = tangramSpec.solutionTans.filter((tan: any) => {
|
|
137
|
+
const tanName = tan.name ?? tan.kind;
|
|
138
|
+
return CANON.has(tanName);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const mask: Poly[] = filteredTans.map((tan: any) => {
|
|
142
|
+
return tan.vertices.map(([x, y]: number[]) => ({
|
|
143
|
+
x: x ?? 0,
|
|
144
|
+
y: -(y ?? 0)
|
|
145
|
+
}));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const primitiveDecomposition = filteredTans.map((tan: any) => ({
|
|
149
|
+
kind: (tan.name ?? tan.kind) as TanKind,
|
|
150
|
+
polygon: tan.vertices.map(([x, y]: number[]) => ({
|
|
151
|
+
x: x ?? 0,
|
|
152
|
+
y: -(y ?? 0)
|
|
153
|
+
}))
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
tangramId: tangramSpec.tangramID,
|
|
158
|
+
mask,
|
|
159
|
+
primitiveDecomposition
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}, [tangrams]);
|
|
163
|
+
|
|
164
|
+
// Find the largest tangram dimensions to determine scaling
|
|
165
|
+
const maxTangramExtent = useMemo(() => {
|
|
166
|
+
let maxExtent = 0;
|
|
167
|
+
for (const t of processedTangrams) {
|
|
168
|
+
const bounds = computeBounds(t.mask);
|
|
169
|
+
maxExtent = Math.max(maxExtent, bounds.width, bounds.height);
|
|
170
|
+
}
|
|
171
|
+
return maxExtent || 1;
|
|
172
|
+
}, [processedTangrams]);
|
|
173
|
+
|
|
174
|
+
// Calculate cell size based on available space
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const calculateCellSize = () => {
|
|
177
|
+
// Use document.documentElement for more accurate viewport size
|
|
178
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
179
|
+
const viewportHeight = document.documentElement.clientHeight;
|
|
180
|
+
|
|
181
|
+
// Reserve space for controls (prompt + input row) and progress bar
|
|
182
|
+
const controlsHeight = controlsRef.current?.offsetHeight ?? 70;
|
|
183
|
+
|
|
184
|
+
// Available space for the grid (subtract progress bar, controls, padding, buffer)
|
|
185
|
+
const availableWidth =
|
|
186
|
+
viewportWidth - CONTAINER_PADDING * 2 - SAFETY_BUFFER;
|
|
187
|
+
const availableHeight =
|
|
188
|
+
viewportHeight - PROGRESS_BAR_HEIGHT - controlsHeight -
|
|
189
|
+
CONTAINER_PADDING * 2 - SAFETY_BUFFER;
|
|
190
|
+
|
|
191
|
+
// Account for gaps between cells AND borders on each cell
|
|
192
|
+
// Each cell has border on all sides, so total border per cell = 2 * CELL_BORDER
|
|
193
|
+
const totalHorizontalGaps = GRID_GAP * (n_cols - 1);
|
|
194
|
+
const totalVerticalGaps = GRID_GAP * (n_rows - 1);
|
|
195
|
+
const totalHorizontalBorders = CELL_BORDER * 2 * n_cols;
|
|
196
|
+
const totalVerticalBorders = CELL_BORDER * 2 * n_rows;
|
|
197
|
+
|
|
198
|
+
// Calculate max cell size that fits in available space
|
|
199
|
+
const maxCellWidth =
|
|
200
|
+
(availableWidth - totalHorizontalGaps - totalHorizontalBorders) / n_cols;
|
|
201
|
+
const maxCellHeight =
|
|
202
|
+
(availableHeight - totalVerticalGaps - totalVerticalBorders) / n_rows;
|
|
203
|
+
|
|
204
|
+
// Use the smaller dimension to keep cells square
|
|
205
|
+
const newCellSize = Math.floor(Math.min(maxCellWidth, maxCellHeight));
|
|
206
|
+
setCellSize(Math.max(newCellSize, 50)); // Minimum 50px
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
calculateCellSize();
|
|
210
|
+
window.addEventListener("resize", calculateCellSize);
|
|
211
|
+
return () => window.removeEventListener("resize", calculateCellSize);
|
|
212
|
+
}, [n_rows, n_cols]);
|
|
213
|
+
|
|
214
|
+
// Scale factor to fit largest tangram in cell with margin
|
|
215
|
+
const tangramScale = useMemo(() => {
|
|
216
|
+
const usableSize = cellSize * (1 - CELL_MARGIN * 2);
|
|
217
|
+
return usableSize / maxTangramExtent;
|
|
218
|
+
}, [cellSize, maxTangramExtent]);
|
|
219
|
+
|
|
220
|
+
// Mapping from TanKind to color index
|
|
221
|
+
const kindToIndex: Record<TanKind, number> = {
|
|
222
|
+
square: 0,
|
|
223
|
+
smalltriangle: 1,
|
|
224
|
+
parallelogram: 2,
|
|
225
|
+
medtriangle: 3,
|
|
226
|
+
largetriangle: 4
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Helper to convert polygon to SVG path
|
|
230
|
+
const pathD = (poly: Poly): string => {
|
|
231
|
+
if (!poly || poly.length === 0) return "";
|
|
232
|
+
const moves = poly.map(
|
|
233
|
+
(p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`
|
|
234
|
+
);
|
|
235
|
+
return moves.join(" ") + " Z";
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Handle submit
|
|
239
|
+
const handleSubmit = () => {
|
|
240
|
+
const rt = Date.now() - trialStartTime.current;
|
|
241
|
+
const trialData = {
|
|
242
|
+
response,
|
|
243
|
+
rt,
|
|
244
|
+
n_rows,
|
|
245
|
+
n_cols,
|
|
246
|
+
tangram_ids: processedTangrams.map((t) => t.tangramId),
|
|
247
|
+
show_tangram_decomposition,
|
|
248
|
+
use_primitive_colors: usePrimitiveColors,
|
|
249
|
+
primitive_color_indices: primitiveColorIndices
|
|
250
|
+
};
|
|
251
|
+
if (onTrialEnd) {
|
|
252
|
+
onTrialEnd(trialData);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Render a single tangram SVG
|
|
257
|
+
const renderTangram = (
|
|
258
|
+
tangramData: (typeof processedTangrams)[0],
|
|
259
|
+
index: number
|
|
260
|
+
) => {
|
|
261
|
+
const { mask, primitiveDecomposition } = tangramData;
|
|
262
|
+
|
|
263
|
+
if (show_tangram_decomposition) {
|
|
264
|
+
// Show individual primitives with optional coloring
|
|
265
|
+
// Scale each primitive and fit to viewport while preserving relative positions
|
|
266
|
+
const scaledPrimitives = primitiveDecomposition.map(
|
|
267
|
+
(prim: { kind: TanKind; polygon: Poly }) => {
|
|
268
|
+
const scaledPoly = prim.polygon.map((p: { x: number; y: number }) => ({
|
|
269
|
+
x: p.x * tangramScale,
|
|
270
|
+
y: p.y * tangramScale
|
|
271
|
+
}));
|
|
272
|
+
return { kind: prim.kind, polygon: scaledPoly };
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Find bounds of all primitives together
|
|
277
|
+
let minX = Infinity,
|
|
278
|
+
minY = Infinity,
|
|
279
|
+
maxX = -Infinity,
|
|
280
|
+
maxY = -Infinity;
|
|
281
|
+
for (const prim of scaledPrimitives) {
|
|
282
|
+
for (const p of prim.polygon) {
|
|
283
|
+
minX = Math.min(minX, p.x);
|
|
284
|
+
minY = Math.min(minY, p.y);
|
|
285
|
+
maxX = Math.max(maxX, p.x);
|
|
286
|
+
maxY = Math.max(maxY, p.y);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Compute translation to center all primitives together in viewport
|
|
291
|
+
const width = maxX - minX;
|
|
292
|
+
const height = maxY - minY;
|
|
293
|
+
const tx = cellSize / 2 - (minX + width / 2);
|
|
294
|
+
const ty = cellSize / 2 - (minY + height / 2);
|
|
295
|
+
|
|
296
|
+
const translatedPrimitives = scaledPrimitives.map(
|
|
297
|
+
(prim: { kind: TanKind; polygon: Poly }) => ({
|
|
298
|
+
kind: prim.kind,
|
|
299
|
+
polygon: prim.polygon.map((p: { x: number; y: number }) => ({
|
|
300
|
+
x: p.x + tx,
|
|
301
|
+
y: p.y + ty
|
|
302
|
+
}))
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<svg
|
|
308
|
+
key={index}
|
|
309
|
+
width={cellSize}
|
|
310
|
+
height={cellSize}
|
|
311
|
+
viewBox={`0 0 ${cellSize} ${cellSize}`}
|
|
312
|
+
style={{
|
|
313
|
+
display: "block",
|
|
314
|
+
background: CONFIG.color.bands.silhouette.fillEven,
|
|
315
|
+
border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
|
|
316
|
+
borderRadius: "8px"
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{translatedPrimitives.map(
|
|
320
|
+
(prim: { kind: TanKind; polygon: Poly }, i: number) => {
|
|
321
|
+
let fillColor: string;
|
|
322
|
+
|
|
323
|
+
if (usePrimitiveColors) {
|
|
324
|
+
// Use primitive colors: map piece type to color via primitiveColorIndices
|
|
325
|
+
const primitiveIndex = kindToIndex[prim.kind];
|
|
326
|
+
if (
|
|
327
|
+
primitiveIndex !== undefined &&
|
|
328
|
+
primitiveColorIndices[primitiveIndex] !== undefined
|
|
329
|
+
) {
|
|
330
|
+
const colorIndex = primitiveColorIndices[primitiveIndex];
|
|
331
|
+
const color = CONFIG.color.primitiveColors[colorIndex];
|
|
332
|
+
fillColor = color || CONFIG.color.piece.validFill;
|
|
333
|
+
} else {
|
|
334
|
+
fillColor = CONFIG.color.piece.validFill;
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// Use default piece color when not using primitive colors
|
|
338
|
+
fillColor = CONFIG.color.piece.validFill;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<path
|
|
343
|
+
key={`prim-${i}`}
|
|
344
|
+
d={pathD(prim.polygon)}
|
|
345
|
+
fill={fillColor}
|
|
346
|
+
opacity={CONFIG.opacity.piece.normal}
|
|
347
|
+
stroke={CONFIG.color.tangramDecomposition.stroke}
|
|
348
|
+
strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
|
|
349
|
+
/>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
)}
|
|
353
|
+
</svg>
|
|
354
|
+
);
|
|
355
|
+
} else {
|
|
356
|
+
// Show as silhouette (merged shape) - scale and center
|
|
357
|
+
const scaledMask = mask.map((poly) =>
|
|
358
|
+
poly.map((p) => ({
|
|
359
|
+
x: p.x * tangramScale,
|
|
360
|
+
y: p.y * tangramScale
|
|
361
|
+
}))
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// Find bounds
|
|
365
|
+
let minX = Infinity,
|
|
366
|
+
minY = Infinity,
|
|
367
|
+
maxX = -Infinity,
|
|
368
|
+
maxY = -Infinity;
|
|
369
|
+
for (const poly of scaledMask) {
|
|
370
|
+
for (const p of poly) {
|
|
371
|
+
minX = Math.min(minX, p.x);
|
|
372
|
+
minY = Math.min(minY, p.y);
|
|
373
|
+
maxX = Math.max(maxX, p.x);
|
|
374
|
+
maxY = Math.max(maxY, p.y);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Center in viewport
|
|
379
|
+
const width = maxX - minX;
|
|
380
|
+
const height = maxY - minY;
|
|
381
|
+
const tx = cellSize / 2 - (minX + width / 2);
|
|
382
|
+
const ty = cellSize / 2 - (minY + height / 2);
|
|
383
|
+
|
|
384
|
+
const placedMask = scaledMask.map((poly) =>
|
|
385
|
+
poly.map((p) => ({ x: p.x + tx, y: p.y + ty }))
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<svg
|
|
390
|
+
key={index}
|
|
391
|
+
width={cellSize}
|
|
392
|
+
height={cellSize}
|
|
393
|
+
viewBox={`0 0 ${cellSize} ${cellSize}`}
|
|
394
|
+
style={{
|
|
395
|
+
display: "block",
|
|
396
|
+
background: CONFIG.color.bands.silhouette.fillEven,
|
|
397
|
+
border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
|
|
398
|
+
borderRadius: "8px"
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
{placedMask.map((poly, i) => (
|
|
402
|
+
<path
|
|
403
|
+
key={`sil-${i}`}
|
|
404
|
+
d={pathD(poly)}
|
|
405
|
+
fill={CONFIG.color.piece.validFill}
|
|
406
|
+
opacity={CONFIG.opacity.piece.normal}
|
|
407
|
+
stroke="none"
|
|
408
|
+
/>
|
|
409
|
+
))}
|
|
410
|
+
</svg>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const isSubmitDisabled = response.trim().length === 0;
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<div
|
|
419
|
+
style={{
|
|
420
|
+
display: "flex",
|
|
421
|
+
flexDirection: "column",
|
|
422
|
+
alignItems: "center",
|
|
423
|
+
justifyContent: "space-between",
|
|
424
|
+
background: CONFIG.color.background,
|
|
425
|
+
width: "100%",
|
|
426
|
+
height: `calc(100vh - ${PROGRESS_BAR_HEIGHT}px)`,
|
|
427
|
+
overflow: "hidden",
|
|
428
|
+
fontFamily: "Roboto, sans-serif",
|
|
429
|
+
boxSizing: "border-box",
|
|
430
|
+
padding: `${CONTAINER_PADDING}px`
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
{/* Grid of tangrams - takes up available space */}
|
|
434
|
+
<div
|
|
435
|
+
style={{
|
|
436
|
+
flex: "1 1 auto",
|
|
437
|
+
display: "flex",
|
|
438
|
+
alignItems: "center",
|
|
439
|
+
justifyContent: "center",
|
|
440
|
+
minHeight: 0
|
|
441
|
+
}}
|
|
442
|
+
>
|
|
443
|
+
<div
|
|
444
|
+
style={{
|
|
445
|
+
display: "grid",
|
|
446
|
+
gridTemplateColumns: `repeat(${n_cols}, ${cellSize}px)`,
|
|
447
|
+
gridTemplateRows: `repeat(${n_rows}, ${cellSize}px)`,
|
|
448
|
+
gap: `${GRID_GAP}px`
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
{processedTangrams.slice(0, n_rows * n_cols).map((t, i) =>
|
|
452
|
+
renderTangram(t, i)
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{/* Controls section - fixed height at bottom */}
|
|
458
|
+
<div
|
|
459
|
+
ref={controlsRef}
|
|
460
|
+
style={{
|
|
461
|
+
flex: "0 0 auto",
|
|
462
|
+
display: "flex",
|
|
463
|
+
flexDirection: "column",
|
|
464
|
+
alignItems: "center",
|
|
465
|
+
gap: "4px",
|
|
466
|
+
paddingTop: "4px"
|
|
467
|
+
}}
|
|
468
|
+
>
|
|
469
|
+
{/* Prompt text */}
|
|
470
|
+
<div
|
|
471
|
+
style={{
|
|
472
|
+
fontSize: "14px",
|
|
473
|
+
textAlign: "center",
|
|
474
|
+
maxWidth: "90vw"
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
{prompt_text}
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
{/* Text input and submit button side by side */}
|
|
481
|
+
<div
|
|
482
|
+
style={{
|
|
483
|
+
display: "flex",
|
|
484
|
+
alignItems: "center",
|
|
485
|
+
gap: "12px"
|
|
486
|
+
}}
|
|
487
|
+
>
|
|
488
|
+
<input
|
|
489
|
+
type="text"
|
|
490
|
+
value={response}
|
|
491
|
+
onChange={(e) => setResponse(e.target.value)}
|
|
492
|
+
style={{
|
|
493
|
+
width: "min(400px, 50vw)",
|
|
494
|
+
padding: "6px 10px",
|
|
495
|
+
fontSize: "14px",
|
|
496
|
+
borderRadius: "6px",
|
|
497
|
+
border: "2px solid #ccc",
|
|
498
|
+
fontFamily: "inherit",
|
|
499
|
+
boxSizing: "border-box"
|
|
500
|
+
}}
|
|
501
|
+
placeholder="Type your response here..."
|
|
502
|
+
/>
|
|
503
|
+
|
|
504
|
+
<button
|
|
505
|
+
className="jspsych-btn"
|
|
506
|
+
onClick={handleSubmit}
|
|
507
|
+
disabled={isSubmitDisabled}
|
|
508
|
+
style={{
|
|
509
|
+
padding: "6px 16px",
|
|
510
|
+
fontSize: "13px",
|
|
511
|
+
cursor: isSubmitDisabled ? "not-allowed" : "pointer",
|
|
512
|
+
opacity: isSubmitDisabled ? 0.5 : 1,
|
|
513
|
+
flexShrink: 0
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
{button_text}
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|