@mathscan/math-exercise-engine 1.1.16 → 1.1.17

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/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { createToken, Lexer, CstParser } from "chevrotain";
2
1
  import { z } from "zod";
2
+ import { createToken, Lexer, CstParser } from "chevrotain";
3
3
  import { create, all } from "mathjs";
4
4
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
5
5
  import React, { useLayoutEffect, useEffect, useRef, useMemo, useCallback, useState, createContext, memo, useReducer, useContext, forwardRef, cloneElement, useId, useImperativeHandle } from "react";
@@ -54,6 +54,8 @@ const AST_TYPES = {
54
54
  VOP_CARRY_ROW: "VopCarryRow",
55
55
  /** Number line layout block */
56
56
  NUMBER_LINE_BLOCK: "NumberLineBlock",
57
+ /** Symmetry exercise on a square grid */
58
+ GRID_SYMMETRY_BLOCK: "GridSymmetryBlock",
57
59
  /** A position marker on the number line */
58
60
  NUMBER_LINE_POSITION: "NumberLinePosition",
59
61
  /** A draggable cursor on the number line */
@@ -185,6 +187,8 @@ const JSON_TYPES = {
185
187
  FRACTION: "fraction",
186
188
  /** Visual fraction widget */
187
189
  VISUAL_FRACTION: "visual-fraction",
190
+ /** Interactive symmetry exercise on a grid */
191
+ GRID_SYMMETRY: "grid-symmetry",
188
192
  /** Ruler geometry tool widget */
189
193
  RULER: "ruler",
190
194
  /** Protractor geometry tool widget */
@@ -199,9 +203,11 @@ const SET_SQUARE_WIDGET_ID = "_set_square";
199
203
  const STATEFUL_WIDGET_TYPES = /* @__PURE__ */ new Set([
200
204
  JSON_TYPES.MATCHER,
201
205
  JSON_TYPES.PAIR_MATCHER,
202
- JSON_TYPES.VISUAL_FRACTION
206
+ JSON_TYPES.VISUAL_FRACTION,
207
+ JSON_TYPES.GRID_SYMMETRY
203
208
  ]);
204
209
  const INLINE_DRAG_OPT_PREFIX = "_inline_drag_opt_";
210
+ const GRID_SYMMETRY_WIDGET_PREFIX = "_grid_symmetry_";
205
211
  const RTL_INLINE_WIDGET_TYPES = /* @__PURE__ */ new Set([
206
212
  JSON_TYPES.INLINE_INPUT,
207
213
  JSON_TYPES.INLINE_DRAG_OPTION,
@@ -244,6 +250,11 @@ function parseFractionKey(key2) {
244
250
  if (!match) return null;
245
251
  return { widgetId: match[1], part: match[2] };
246
252
  }
253
+ const GridCellSchema = z.tuple([
254
+ z.number().int().positive(),
255
+ z.number().int().positive()
256
+ ]);
257
+ const gridCellKey = ([row, col]) => `${row},${col}`;
247
258
  const Newline = createToken({
248
259
  name: "Newline",
249
260
  pattern: /\r?\n/,
@@ -325,6 +336,10 @@ const NUMBER_LINE = createToken({
325
336
  name: "NUMBER_LINE",
326
337
  pattern: /NUMBER_LINE/
327
338
  });
339
+ const GRID_SYMMETRY = createToken({
340
+ name: "GRID_SYMMETRY",
341
+ pattern: /GRID_SYMMETRY/
342
+ });
328
343
  const POSITION = createToken({
329
344
  name: "POSITION",
330
345
  pattern: /POSITION/
@@ -533,6 +548,7 @@ const allTokens = [
533
548
  W_N_I,
534
549
  NUMERIC_INPUT,
535
550
  NUMBER_LINE,
551
+ GRID_SYMMETRY,
536
552
  POSITION,
537
553
  CURSOR,
538
554
  ARROW,
@@ -708,6 +724,10 @@ class MathExerciseParser extends CstParser {
708
724
  GATE: this.BACKTRACK(this.numberLine),
709
725
  ALT: () => this.SUBRULE(this.numberLine)
710
726
  },
727
+ {
728
+ GATE: this.BACKTRACK(this.gridSymmetry),
729
+ ALT: () => this.SUBRULE(this.gridSymmetry)
730
+ },
711
731
  {
712
732
  GATE: this.BACKTRACK(this.pairMatcher),
713
733
  ALT: () => this.SUBRULE(this.pairMatcher)
@@ -723,6 +743,18 @@ class MathExerciseParser extends CstParser {
723
743
  { ALT: () => this.SUBRULE(this.line) }
724
744
  ]);
725
745
  });
746
+ this.gridSymmetry = this.RULE("gridSymmetry", () => {
747
+ this.MANY(() => {
748
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
749
+ });
750
+ this.CONSUME(GRID_SYMMETRY);
751
+ this.CONSUME(LBracket);
752
+ this.SUBRULE(this.parameterList);
753
+ this.CONSUME(RBracket);
754
+ this.MANY2(() => {
755
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
756
+ });
757
+ });
726
758
  this.table = this.RULE("table", () => {
727
759
  this.MANY(() => {
728
760
  this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
@@ -1436,27 +1468,51 @@ class MathExerciseParser extends CstParser {
1436
1468
  });
1437
1469
  this.parameterList = this.RULE("parameterList", () => {
1438
1470
  this.MANY(() => {
1439
- this.CONSUME(Space);
1471
+ this.OR([
1472
+ { ALT: () => this.CONSUME(Space) },
1473
+ { ALT: () => this.CONSUME(Newline) }
1474
+ ]);
1440
1475
  });
1441
1476
  this.SUBRULE(this.parameter);
1442
1477
  this.MANY2(() => {
1443
1478
  this.MANY3(() => {
1444
- this.CONSUME2(Space);
1479
+ this.OR2([
1480
+ { ALT: () => this.CONSUME2(Space) },
1481
+ { ALT: () => this.CONSUME2(Newline) }
1482
+ ]);
1445
1483
  });
1446
1484
  this.CONSUME(Comma);
1447
1485
  this.MANY4(() => {
1448
- this.CONSUME3(Space);
1486
+ this.OR3([
1487
+ { ALT: () => this.CONSUME3(Space) },
1488
+ { ALT: () => this.CONSUME3(Newline) }
1489
+ ]);
1449
1490
  });
1450
1491
  this.SUBRULE2(this.parameter);
1451
1492
  });
1452
1493
  this.MANY5(() => {
1453
- this.CONSUME4(Space);
1494
+ this.OR4([
1495
+ { ALT: () => this.CONSUME4(Space) },
1496
+ { ALT: () => this.CONSUME4(Newline) }
1497
+ ]);
1454
1498
  });
1455
1499
  });
1456
1500
  this.parameter = this.RULE("parameter", () => {
1457
1501
  this.CONSUME(Identifier, { LABEL: "key" });
1502
+ this.MANY(() => {
1503
+ this.OR([
1504
+ { ALT: () => this.CONSUME(Space) },
1505
+ { ALT: () => this.CONSUME(Newline) }
1506
+ ]);
1507
+ });
1458
1508
  this.CONSUME(Equals);
1459
- this.OR([
1509
+ this.MANY2(() => {
1510
+ this.OR2([
1511
+ { ALT: () => this.CONSUME2(Space) },
1512
+ { ALT: () => this.CONSUME2(Newline) }
1513
+ ]);
1514
+ });
1515
+ this.OR3([
1460
1516
  // String value
1461
1517
  { ALT: () => this.CONSUME(StringLiteral, { LABEL: "value" }) },
1462
1518
  // Number value
@@ -1465,6 +1521,11 @@ class MathExerciseParser extends CstParser {
1465
1521
  { ALT: () => this.CONSUME2(Identifier, { LABEL: "value" }) },
1466
1522
  // Tuple value: (start, end)
1467
1523
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
1524
+ // Number-pair list enclosed in braces: {[1, 2], [3, 4]}
1525
+ {
1526
+ GATE: () => this.isBracedNumberPairListAhead(),
1527
+ ALT: () => this.SUBRULE(this.bracedNumberPairList, { LABEL: "value" })
1528
+ },
1468
1529
  // Curly-brace string list: {"a", "b", "c"}
1469
1530
  { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
1470
1531
  // Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
@@ -1520,8 +1581,52 @@ class MathExerciseParser extends CstParser {
1520
1581
  this.SUBRULE(this.stringList);
1521
1582
  this.CONSUME(RBrace);
1522
1583
  });
1584
+ this.bracedNumberPairList = this.RULE("bracedNumberPairList", () => {
1585
+ this.CONSUME(LBrace);
1586
+ this.MANY(() => {
1587
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1588
+ });
1589
+ this.OPTION(() => {
1590
+ this.SUBRULE(this.numberPair);
1591
+ this.MANY2(() => {
1592
+ this.MANY3(() => {
1593
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1594
+ });
1595
+ this.CONSUME(Comma);
1596
+ this.MANY4(() => {
1597
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1598
+ });
1599
+ this.SUBRULE2(this.numberPair);
1600
+ });
1601
+ });
1602
+ this.MANY5(() => {
1603
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1604
+ });
1605
+ this.CONSUME(RBrace);
1606
+ });
1607
+ this.numberPair = this.RULE("numberPair", () => {
1608
+ this.CONSUME(LBracket);
1609
+ this.MANY(() => this.CONSUME(Space));
1610
+ this.CONSUME(NumberLiteral, { LABEL: "row" });
1611
+ this.MANY2(() => this.CONSUME2(Space));
1612
+ this.CONSUME(Comma);
1613
+ this.MANY3(() => this.CONSUME3(Space));
1614
+ this.CONSUME2(NumberLiteral, { LABEL: "col" });
1615
+ this.MANY4(() => this.CONSUME4(Space));
1616
+ this.CONSUME(RBracket);
1617
+ });
1523
1618
  this.performSelfAnalysis();
1524
1619
  }
1620
+ /** Distinguish `{[number, number], ...}` and the valid empty form `{}` from string lists. */
1621
+ isBracedNumberPairListAhead() {
1622
+ if (this.LA(1).tokenType !== LBrace) return false;
1623
+ let offset = 2;
1624
+ while (this.LA(offset).tokenType === Space || this.LA(offset).tokenType === Newline) {
1625
+ offset++;
1626
+ }
1627
+ const firstContentToken = this.LA(offset).tokenType;
1628
+ return firstContentToken === LBracket || firstContentToken === RBrace;
1629
+ }
1525
1630
  }
1526
1631
  const parser = new MathExerciseParser();
1527
1632
  function parseOrThrow(schema, data, label) {
@@ -2066,6 +2171,135 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style, lab
2066
2171
  positions
2067
2172
  });
2068
2173
  }
2174
+ const GRID_SYMMETRY_AXES = ["vertical", "horizontal"];
2175
+ const GRID_SYMMETRY_INTERACTIVE_AREAS = ["left", "right", "top", "bottom"];
2176
+ const GRID_SYMMETRY_MAX_ROWS = 20;
2177
+ const GRID_SYMMETRY_MAX_COLS = 20;
2178
+ const GRID_SYMMETRY_MIN_CELL_SIZE = 0.25;
2179
+ const GRID_SYMMETRY_MAX_CELL_SIZE = 2;
2180
+ function isCellInInteractiveArea(cell, rows, cols, area) {
2181
+ const [row, col] = cell;
2182
+ switch (area) {
2183
+ case "left":
2184
+ return col <= Math.floor(cols / 2);
2185
+ case "right":
2186
+ return col > Math.ceil(cols / 2);
2187
+ case "top":
2188
+ return row <= Math.floor(rows / 2);
2189
+ case "bottom":
2190
+ return row > Math.ceil(rows / 2);
2191
+ }
2192
+ }
2193
+ function reflectGridSymmetryCell(cell, rows, cols, axis) {
2194
+ const [row, col] = cell;
2195
+ return axis === "vertical" ? [row, cols + 1 - col] : [rows + 1 - row, col];
2196
+ }
2197
+ function computeGridSymmetryExpectedCells(data) {
2198
+ const seen = /* @__PURE__ */ new Set();
2199
+ const expected = [];
2200
+ for (const cell of data.cells) {
2201
+ if (isCellInInteractiveArea(cell, data.rows, data.cols, data.interactiveArea)) continue;
2202
+ const reflected = reflectGridSymmetryCell(cell, data.rows, data.cols, data.axis);
2203
+ if (!isCellInInteractiveArea(reflected, data.rows, data.cols, data.interactiveArea)) continue;
2204
+ const key2 = gridCellKey(reflected);
2205
+ if (!seen.has(key2)) {
2206
+ seen.add(key2);
2207
+ expected.push(reflected);
2208
+ }
2209
+ }
2210
+ return expected.sort(([ar, ac], [br, bc]) => ar - br || ac - bc);
2211
+ }
2212
+ function computeGridSymmetryCellsToFill(data) {
2213
+ const initial = new Set(data.cells.map(gridCellKey));
2214
+ return computeGridSymmetryExpectedCells(data).filter((cell) => !initial.has(gridCellKey(cell)));
2215
+ }
2216
+ const GridSymmetryBlockSchema = z.object({
2217
+ type: z.literal(AST_TYPES.GRID_SYMMETRY_BLOCK),
2218
+ rows: z.number().int("The number of rows must be a whole number.").positive("The number of rows must be greater than 0.").max(GRID_SYMMETRY_MAX_ROWS, {
2219
+ message: `The number of rows cannot exceed ${GRID_SYMMETRY_MAX_ROWS}.`
2220
+ }),
2221
+ cols: z.number().int("The number of columns must be a whole number.").positive("The number of columns must be greater than 0.").max(GRID_SYMMETRY_MAX_COLS, {
2222
+ message: `The number of columns cannot exceed ${GRID_SYMMETRY_MAX_COLS}.`
2223
+ }),
2224
+ axis: z.enum(GRID_SYMMETRY_AXES),
2225
+ interactiveArea: z.enum(GRID_SYMMETRY_INTERACTIVE_AREAS),
2226
+ cellSize: z.number().finite().min(
2227
+ GRID_SYMMETRY_MIN_CELL_SIZE,
2228
+ `Cell size must be between ${GRID_SYMMETRY_MIN_CELL_SIZE} and ${GRID_SYMMETRY_MAX_CELL_SIZE}.`
2229
+ ).max(
2230
+ GRID_SYMMETRY_MAX_CELL_SIZE,
2231
+ `Cell size must be between ${GRID_SYMMETRY_MIN_CELL_SIZE} and ${GRID_SYMMETRY_MAX_CELL_SIZE}.`
2232
+ ).default(1),
2233
+ cells: z.array(GridCellSchema)
2234
+ }).superRefine((data, ctx) => {
2235
+ const compatible = data.axis === "vertical" ? data.interactiveArea === "left" || data.interactiveArea === "right" : data.interactiveArea === "top" || data.interactiveArea === "bottom";
2236
+ if (!compatible) {
2237
+ ctx.addIssue({
2238
+ code: z.ZodIssueCode.custom,
2239
+ path: ["interactiveArea"],
2240
+ message: data.axis === "vertical" ? 'For a vertical axis, the interactive area must be "left" or "right".' : 'For a horizontal axis, the interactive area must be "top" or "bottom".'
2241
+ });
2242
+ }
2243
+ if (data.axis === "vertical" && data.cols % 2 !== 0) {
2244
+ ctx.addIssue({
2245
+ code: z.ZodIssueCode.custom,
2246
+ path: ["cols"],
2247
+ message: `For a vertical axis, the number of columns must be even. Received: ${data.cols}.`
2248
+ });
2249
+ }
2250
+ if (data.axis === "horizontal" && data.rows % 2 !== 0) {
2251
+ ctx.addIssue({
2252
+ code: z.ZodIssueCode.custom,
2253
+ path: ["rows"],
2254
+ message: `For a horizontal axis, the number of rows must be even. Received: ${data.rows}.`
2255
+ });
2256
+ }
2257
+ const seen = /* @__PURE__ */ new Set();
2258
+ data.cells.forEach((cell, index) => {
2259
+ const [row, col] = cell;
2260
+ if (row > data.rows || col > data.cols) {
2261
+ ctx.addIssue({
2262
+ code: z.ZodIssueCode.custom,
2263
+ path: ["cells", index],
2264
+ message: `Cell [${row},${col}] is outside the ${data.rows} × ${data.cols} grid.`
2265
+ });
2266
+ }
2267
+ const key2 = gridCellKey(cell);
2268
+ if (seen.has(key2)) {
2269
+ ctx.addIssue({
2270
+ code: z.ZodIssueCode.custom,
2271
+ path: ["cells", index],
2272
+ message: `Cell [${row},${col}] is listed more than once.`
2273
+ });
2274
+ }
2275
+ seen.add(key2);
2276
+ });
2277
+ if (!compatible) return;
2278
+ const expected = new Set(computeGridSymmetryExpectedCells(data).map(gridCellKey));
2279
+ data.cells.forEach((cell, index) => {
2280
+ if (isCellInInteractiveArea(cell, data.rows, data.cols, data.interactiveArea) && !expected.has(gridCellKey(cell))) {
2281
+ ctx.addIssue({
2282
+ code: z.ZodIssueCode.custom,
2283
+ path: ["cells", index],
2284
+ message: `Prefilled cell [${cell[0]},${cell[1]}] does not match the expected symmetry.`
2285
+ });
2286
+ }
2287
+ });
2288
+ });
2289
+ function createGridSymmetryBlock(rows, cols, axis, interactiveArea, cells, cellSize = 1) {
2290
+ return GridSymmetryBlockSchema.parse({
2291
+ type: AST_TYPES.GRID_SYMMETRY_BLOCK,
2292
+ rows,
2293
+ cols,
2294
+ axis,
2295
+ interactiveArea,
2296
+ cellSize,
2297
+ cells
2298
+ });
2299
+ }
2300
+ function isGridSymmetryBlock(value) {
2301
+ return GridSymmetryBlockSchema.safeParse(value).success;
2302
+ }
2069
2303
  const TableCellSchema = z.object({
2070
2304
  type: z.literal(AST_TYPES.TABLE_CELL),
2071
2305
  colspan: z.number().int().positive().optional(),
@@ -2565,7 +2799,7 @@ function isMixedValidation(value) {
2565
2799
  function isValidationNode(value) {
2566
2800
  return ValidationNodeSchema.safeParse(value).success;
2567
2801
  }
2568
- const ContentBlockSchema = z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema]);
2802
+ const ContentBlockSchema = z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema, GridSymmetryBlockSchema]);
2569
2803
  const ExerciseDocumentSchema = z.object({
2570
2804
  type: z.literal(AST_TYPES.EXERCISE_DOCUMENT),
2571
2805
  question: TextBlockSchema.optional(),
@@ -2667,12 +2901,24 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2667
2901
  if (ctx.table) return this.visit(ctx.table);
2668
2902
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
2669
2903
  if (ctx.numberLine) return this.visit(ctx.numberLine);
2904
+ if (ctx.gridSymmetry) return this.visit(ctx.gridSymmetry);
2670
2905
  if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
2671
2906
  if (ctx.matcher) return this.visit(ctx.matcher);
2672
2907
  if (ctx.verticalOperation) return this.visit(ctx.verticalOperation);
2673
2908
  if (ctx.line) return this.visit(ctx.line);
2674
2909
  throw new Error("Unknown content block");
2675
2910
  }
2911
+ gridSymmetry(ctx) {
2912
+ const params = this.visit(ctx.parameterList);
2913
+ return createGridSymmetryBlock(
2914
+ Number(params.rows),
2915
+ Number(params.cols),
2916
+ params.axis,
2917
+ params.interactiveArea,
2918
+ params.cells,
2919
+ params.cellSize === void 0 ? 1 : Number(params.cellSize)
2920
+ );
2921
+ }
2676
2922
  radioButtons(ctx) {
2677
2923
  const params = this.visit(ctx.parameterList);
2678
2924
  const name = params.name;
@@ -3261,6 +3507,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3261
3507
  curlyStringList(ctx) {
3262
3508
  return this.visit(ctx.stringList);
3263
3509
  }
3510
+ bracedNumberPairList(ctx) {
3511
+ return ctx.numberPair ? ctx.numberPair.map((pair) => this.visit(pair)) : [];
3512
+ }
3513
+ numberPair(ctx) {
3514
+ return [
3515
+ parseFloat(ctx.row[0].image),
3516
+ parseFloat(ctx.col[0].image)
3517
+ ];
3518
+ }
3264
3519
  // ═══════════════════════════════════════════════════════════════════════════
3265
3520
  // Helpers
3266
3521
  // ═══════════════════════════════════════════════════════════════════════════
@@ -3637,6 +3892,34 @@ const VisualFractionJSONSchema = z.object({
3637
3892
  */
3638
3893
  size: z.number().positive().optional()
3639
3894
  }).superRefine(validateVisualFractionCapacity);
3895
+ const GridSymmetryJSONSchema = z.object({
3896
+ type: z.literal(JSON_TYPES.GRID_SYMMETRY),
3897
+ rows: z.number().int().positive(),
3898
+ cols: z.number().int().positive(),
3899
+ axis: z.enum(["vertical", "horizontal"]),
3900
+ interactiveArea: z.enum(["left", "right", "top", "bottom"]),
3901
+ cellSize: z.number().finite().positive().default(1),
3902
+ cells: z.array(GridCellSchema)
3903
+ }).superRefine((data, ctx) => {
3904
+ const result = GridSymmetryBlockSchema.safeParse({
3905
+ type: AST_TYPES.GRID_SYMMETRY_BLOCK,
3906
+ rows: data.rows,
3907
+ cols: data.cols,
3908
+ axis: data.axis,
3909
+ interactiveArea: data.interactiveArea,
3910
+ cellSize: data.cellSize,
3911
+ cells: data.cells
3912
+ });
3913
+ if (!result.success) {
3914
+ for (const issue of result.error.issues) {
3915
+ ctx.addIssue({
3916
+ code: z.ZodIssueCode.custom,
3917
+ path: issue.path,
3918
+ message: issue.message
3919
+ });
3920
+ }
3921
+ }
3922
+ });
3640
3923
  const RulerWidgetSchema = z.object({
3641
3924
  type: z.literal(JSON_TYPES.RULER),
3642
3925
  size: z.number().optional()
@@ -3659,6 +3942,7 @@ const WidgetJSONSchema = z.union([
3659
3942
  MatcherWidgetJSONSchema,
3660
3943
  PairMatcherWidgetJSONSchema,
3661
3944
  VisualFractionJSONSchema,
3945
+ GridSymmetryJSONSchema,
3662
3946
  RulerWidgetSchema,
3663
3947
  ProtractorWidgetSchema,
3664
3948
  SetSquareWidgetSchema
@@ -3674,7 +3958,8 @@ const ValidationJSONSchema = z.object({
3674
3958
  z.string(),
3675
3959
  z.number(),
3676
3960
  z.array(z.array(z.string().min(1)).min(1)).min(1),
3677
- z.array(z.number().int().nonnegative())
3961
+ z.array(z.number().int().nonnegative()),
3962
+ z.array(GridCellSchema)
3678
3963
  ])
3679
3964
  ).optional(),
3680
3965
  expression: z.string().optional(),
@@ -3772,6 +4057,9 @@ function isNumericKeypadJSON(widget) {
3772
4057
  function isFractionJSON(widget) {
3773
4058
  return widget.type === JSON_TYPES.FRACTION;
3774
4059
  }
4060
+ function isGridSymmetryJSON(widget) {
4061
+ return widget.type === JSON_TYPES.GRID_SYMMETRY;
4062
+ }
3775
4063
  function isRulerWidget(widget) {
3776
4064
  return widget.type === JSON_TYPES.RULER;
3777
4065
  }
@@ -9279,6 +9567,7 @@ function toJSON(ast) {
9279
9567
  const idGenerator = new LayoutIdGenerator();
9280
9568
  const widgets = {};
9281
9569
  const answers = {};
9570
+ let gridSymmetryCounter = 0;
9282
9571
  const allInlines = collectAllInlines(ast.lines);
9283
9572
  const inlineDragIds = /* @__PURE__ */ new Map();
9284
9573
  {
@@ -9333,6 +9622,34 @@ function toJSON(ast) {
9333
9622
  });
9334
9623
  } else if (block.type === AST_TYPES.VERTICAL_OPERATION_BLOCK) {
9335
9624
  layout.push(transformVerticalOperation(block, idGenerator));
9625
+ } else if (block.type === AST_TYPES.GRID_SYMMETRY_BLOCK) {
9626
+ const grid = block;
9627
+ const widgetId = `${GRID_SYMMETRY_WIDGET_PREFIX}${gridSymmetryCounter++}`;
9628
+ widgets[widgetId] = {
9629
+ type: JSON_TYPES.GRID_SYMMETRY,
9630
+ rows: grid.rows,
9631
+ cols: grid.cols,
9632
+ axis: grid.axis,
9633
+ interactiveArea: grid.interactiveArea,
9634
+ cellSize: grid.cellSize,
9635
+ cells: grid.cells
9636
+ };
9637
+ const prefilled = grid.cells.filter((cell) => isCellInInteractiveArea(
9638
+ cell,
9639
+ grid.rows,
9640
+ grid.cols,
9641
+ grid.interactiveArea
9642
+ ));
9643
+ answers[widgetId] = [...prefilled, ...computeGridSymmetryCellsToFill(grid)].sort(([ar, ac], [br, bc]) => ar - br || ac - bc);
9644
+ layout.push({
9645
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
9646
+ type: JSON_TYPES.WIDGET_REF,
9647
+ widgetId
9648
+ });
9649
+ layout.push({
9650
+ id: idGenerator.generate(JSON_TYPES.LINEBREAK),
9651
+ type: JSON_TYPES.LINEBREAK
9652
+ });
9336
9653
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
9337
9654
  const radioBlock = block;
9338
9655
  layout.push({
@@ -9930,6 +10247,18 @@ function areVisualFractionCountsEqual(expected, result) {
9930
10247
  if (expected.length !== result.length) return false;
9931
10248
  return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
9932
10249
  }
10250
+ function isValidGridSymmetryCell(value, rows, cols) {
10251
+ if (!Array.isArray(value) || value.length !== 2) return false;
10252
+ const [row, col] = value;
10253
+ return Number.isInteger(row) && Number.isInteger(col) && row >= 1 && row <= rows && col >= 1 && col <= cols;
10254
+ }
10255
+ function areGridSymmetryCellsEqual(expected, actual, rows, cols) {
10256
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
10257
+ if (!actual.every((cell) => isValidGridSymmetryCell(cell, rows, cols))) return false;
10258
+ const actualKeys = new Set(actual.map(gridCellKey));
10259
+ if (actualKeys.size !== actual.length) return false;
10260
+ return expected.every((cell) => actualKeys.has(gridCellKey(cell)));
10261
+ }
9933
10262
  const math$1 = create(all);
9934
10263
  function collectNumberLines(items) {
9935
10264
  const result = [];
@@ -10003,6 +10332,20 @@ function validateSimple(exercise, userInputs) {
10003
10332
  else allCorrect = false;
10004
10333
  continue;
10005
10334
  }
10335
+ if (widget?.type === JSON_TYPES.GRID_SYMMETRY) {
10336
+ const userValue2 = userInputs[widgetId];
10337
+ const isCorrect2 = areGridSymmetryCellsEqual(
10338
+ expected,
10339
+ userValue2,
10340
+ widget.rows,
10341
+ widget.cols
10342
+ );
10343
+ details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
10344
+ totalCount++;
10345
+ if (isCorrect2) correctCount++;
10346
+ else allCorrect = false;
10347
+ continue;
10348
+ }
10006
10349
  totalCount++;
10007
10350
  const userValue = userInputs[widgetId];
10008
10351
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -10433,9 +10776,19 @@ function getMissingInputs(exercise, userInputs) {
10433
10776
  }
10434
10777
  const inputs = preprocessFractionValues(exercise, userInputs);
10435
10778
  const isEmpty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
10779
+ const isMissingAnswer = (widgetId) => {
10780
+ const value = inputs[widgetId];
10781
+ const widget = exercise.widgets[widgetId];
10782
+ if (widget?.type === JSON_TYPES.GRID_SYMMETRY) {
10783
+ const expected = exercise.validation?.answers?.[widgetId];
10784
+ const expectedCount = Array.isArray(expected) ? expected.length : 0;
10785
+ return !Array.isArray(value) || value.length < expectedCount;
10786
+ }
10787
+ return !(widgetId in inputs) || isEmpty(value);
10788
+ };
10436
10789
  if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
10437
10790
  for (const widgetId of Object.keys(exercise.validation.answers)) {
10438
- if (!(widgetId in inputs) || isEmpty(inputs[widgetId])) {
10791
+ if (isMissingAnswer(widgetId)) {
10439
10792
  missing.push(widgetId);
10440
10793
  }
10441
10794
  }
@@ -10451,7 +10804,7 @@ function getMissingInputs(exercise, userInputs) {
10451
10804
  ...exercise.validation.variables ?? []
10452
10805
  ]);
10453
10806
  for (const k of keys) {
10454
- if (!(k in inputs) || isEmpty(inputs[k])) missing.push(k);
10807
+ if (isMissingAnswer(k)) missing.push(k);
10455
10808
  }
10456
10809
  }
10457
10810
  return missing;
@@ -15874,6 +16227,94 @@ function getSizeStyle(size, _shape) {
15874
16227
  "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
15875
16228
  };
15876
16229
  }
16230
+ const GridSymmetry = ({
16231
+ widgetId,
16232
+ widget,
16233
+ value,
16234
+ validationStatus = null,
16235
+ onChange
16236
+ }) => {
16237
+ const initialKeys = useMemo(() => new Set(widget.cells.map(gridCellKey)), [widget.cells]);
16238
+ const initialInteractiveCells = useMemo(
16239
+ () => widget.cells.filter(
16240
+ (cell) => isCellInInteractiveArea(cell, widget.rows, widget.cols, widget.interactiveArea)
16241
+ ),
16242
+ [widget.cells, widget.rows, widget.cols, widget.interactiveArea]
16243
+ );
16244
+ const selectedCells = value ?? initialInteractiveCells;
16245
+ const selectedKeys = useMemo(() => new Set(selectedCells.map(gridCellKey)), [selectedCells]);
16246
+ const toggleCell = (cell) => {
16247
+ const key2 = gridCellKey(cell);
16248
+ if (initialKeys.has(key2)) return;
16249
+ const next = selectedKeys.has(key2) ? selectedCells.filter((candidate) => gridCellKey(candidate) !== key2) : [...selectedCells, cell];
16250
+ onChange(widgetId, next);
16251
+ };
16252
+ const statusClass = validationStatus ? ` grid-symmetry--${validationStatus}` : "";
16253
+ const cells = [];
16254
+ for (let row = 1; row <= widget.rows; row++) {
16255
+ for (let col = 1; col <= widget.cols; col++) {
16256
+ const cell = [row, col];
16257
+ const key2 = gridCellKey(cell);
16258
+ const interactive = isCellInInteractiveArea(
16259
+ cell,
16260
+ widget.rows,
16261
+ widget.cols,
16262
+ widget.interactiveArea
16263
+ );
16264
+ const initial = initialKeys.has(key2);
16265
+ const selected = selectedKeys.has(key2);
16266
+ const locked = initial || !interactive;
16267
+ const roleClass = initial ? interactive ? " grid-symmetry__cell--prefilled" : " grid-symmetry__cell--reference" : selected ? " grid-symmetry__cell--answer" : "";
16268
+ const className = `grid-symmetry__cell${interactive ? " grid-symmetry__cell--interactive" : ""}${locked ? "" : " grid-symmetry__cell--editable"}${selected || initial ? " grid-symmetry__cell--selected" : ""}${roleClass}`;
16269
+ const label = `Row ${row}, column ${col}`;
16270
+ cells.push(
16271
+ locked ? /* @__PURE__ */ jsx(
16272
+ "span",
16273
+ {
16274
+ className,
16275
+ role: initial ? "img" : void 0,
16276
+ "aria-label": initial ? `${label}, filled` : void 0,
16277
+ "aria-hidden": initial ? void 0 : true
16278
+ },
16279
+ key2
16280
+ ) : /* @__PURE__ */ jsx(
16281
+ "button",
16282
+ {
16283
+ type: "button",
16284
+ className,
16285
+ "aria-label": label,
16286
+ "aria-pressed": selected,
16287
+ onClick: () => toggleCell(cell)
16288
+ },
16289
+ key2
16290
+ )
16291
+ );
16292
+ }
16293
+ }
16294
+ return /* @__PURE__ */ jsx("div", { className: `grid-symmetry${statusClass}`, "data-testid": "grid-symmetry", children: /* @__PURE__ */ jsxs(
16295
+ "div",
16296
+ {
16297
+ className: "grid-symmetry__grid",
16298
+ style: {
16299
+ "--grid-symmetry-cell-size": `calc(48px * ${widget.cellSize})`,
16300
+ gridTemplateColumns: `repeat(${widget.cols}, var(--grid-symmetry-cell-size))`,
16301
+ gridTemplateRows: `repeat(${widget.rows}, var(--grid-symmetry-cell-size))`
16302
+ },
16303
+ role: "group",
16304
+ "aria-label": `Symmetry grid, ${widget.rows} rows by ${widget.cols} columns`,
16305
+ children: [
16306
+ cells,
16307
+ /* @__PURE__ */ jsx(
16308
+ "span",
16309
+ {
16310
+ className: `grid-symmetry__axis grid-symmetry__axis--${widget.axis}`,
16311
+ "aria-hidden": "true"
16312
+ }
16313
+ )
16314
+ ]
16315
+ }
16316
+ ) });
16317
+ };
15877
16318
  const RadioOptions = ({
15878
16319
  name,
15879
16320
  options,
@@ -16522,6 +16963,9 @@ function isPairMatcherWidget(widget) {
16522
16963
  function isVisualFractionWidget(widget) {
16523
16964
  return widget.type === "visual-fraction";
16524
16965
  }
16966
+ function isGridSymmetryWidget(widget) {
16967
+ return widget.type === "grid-symmetry";
16968
+ }
16525
16969
  const WidgetRenderer = ({
16526
16970
  widget,
16527
16971
  widgetId,
@@ -16666,6 +17110,21 @@ const WidgetRenderer = ({
16666
17110
  }
16667
17111
  );
16668
17112
  }
17113
+ if (isGridSymmetryWidget(widget)) {
17114
+ const cells = Array.isArray(value) && value.every(
17115
+ (cell) => Array.isArray(cell) && cell.length === 2 && cell.every(Number.isInteger)
17116
+ ) ? value : void 0;
17117
+ return /* @__PURE__ */ jsx(
17118
+ GridSymmetry,
17119
+ {
17120
+ widgetId,
17121
+ widget,
17122
+ value: cells,
17123
+ validationStatus,
17124
+ onChange: (id, nextValue) => onChange(id, nextValue)
17125
+ }
17126
+ );
17127
+ }
16669
17128
  return null;
16670
17129
  };
16671
17130
  const ExpandDiamondIcon = () => {
@@ -18730,6 +19189,14 @@ function buildInitialValues(json) {
18730
19189
  const initial = {};
18731
19190
  for (const [id, widget] of Object.entries(json.widgets)) {
18732
19191
  if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
19192
+ if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
19193
+ initial[id] = widget.cells.filter((cell) => isCellInInteractiveArea(
19194
+ cell,
19195
+ widget.rows,
19196
+ widget.cols,
19197
+ widget.interactiveArea
19198
+ ));
19199
+ }
18733
19200
  }
18734
19201
  return initial;
18735
19202
  }
@@ -19159,6 +19626,15 @@ export {
19159
19626
  FractionInlineSchema,
19160
19627
  FractionJSONSchema,
19161
19628
  FreeTextInlineSchema,
19629
+ GRID_SYMMETRY_AXES,
19630
+ GRID_SYMMETRY_INTERACTIVE_AREAS,
19631
+ GRID_SYMMETRY_MAX_CELL_SIZE,
19632
+ GRID_SYMMETRY_MAX_COLS,
19633
+ GRID_SYMMETRY_MAX_ROWS,
19634
+ GRID_SYMMETRY_MIN_CELL_SIZE,
19635
+ GridCellSchema,
19636
+ GridSymmetryBlockSchema,
19637
+ GridSymmetryJSONSchema,
19162
19638
  GroupItemSchema,
19163
19639
  HighlightedTextInlineSchema,
19164
19640
  HighlightedTextSchema,
@@ -19245,6 +19721,8 @@ export {
19245
19721
  WidgetRenderer,
19246
19722
  compile,
19247
19723
  compileWithAST,
19724
+ computeGridSymmetryCellsToFill,
19725
+ computeGridSymmetryExpectedCells,
19248
19726
  createBoldText,
19249
19727
  createColorText,
19250
19728
  createDisplayClock,
@@ -19253,6 +19731,7 @@ export {
19253
19731
  createExpressionValidation,
19254
19732
  createFraction,
19255
19733
  createFreeText,
19734
+ createGridSymmetryBlock,
19256
19735
  createHighlightedText,
19257
19736
  createImage,
19258
19737
  createInlineDragOption,
@@ -19291,6 +19770,7 @@ export {
19291
19770
  getFractionKey,
19292
19771
  getMissingInputs,
19293
19772
  isBoldText,
19773
+ isCellInInteractiveArea,
19294
19774
  isClock,
19295
19775
  isClockItem,
19296
19776
  isColorText,
@@ -19302,6 +19782,8 @@ export {
19302
19782
  isFractionJSON,
19303
19783
  isFractionKey,
19304
19784
  isFreeText,
19785
+ isGridSymmetryBlock,
19786
+ isGridSymmetryJSON,
19305
19787
  isGroupItem,
19306
19788
  isHighlightedText,
19307
19789
  isImage,
@@ -19354,6 +19836,7 @@ export {
19354
19836
  parse,
19355
19837
  parseFractionKey,
19356
19838
  parseSafe,
19839
+ reflectGridSymmetryCell,
19357
19840
  renderExercise,
19358
19841
  toAST,
19359
19842
  toJSON,