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