@mathscan/math-exercise-engine 1.0.8 → 1.0.10

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
@@ -36,6 +36,10 @@ const AST_TYPES = {
36
36
  TABLE_ROW: "TableRow",
37
37
  /** Cell within a table row */
38
38
  TABLE_CELL: "TableCell",
39
+ /** Number line layout block */
40
+ NUMBER_LINE_BLOCK: "NumberLineBlock",
41
+ /** A position marker on the number line */
42
+ NUMBER_LINE_POSITION: "NumberLinePosition",
39
43
  // ═══════════════════════════════════════════════════════════════════════════
40
44
  // Tools
41
45
  // ═══════════════════════════════════════════════════════════════════════════
@@ -88,6 +92,10 @@ const JSON_TYPES = {
88
92
  TABLE_ROW: "table-row",
89
93
  /** Cell within a table */
90
94
  TABLE_CELL: "table-cell",
95
+ /** Number line layout block */
96
+ NUMBER_LINE: "number-line",
97
+ /** Position marker within a number line */
98
+ NUMBER_LINE_POSITION: "number-line-position",
91
99
  // ═══════════════════════════════════════════════════════════════════════════
92
100
  // Widget Types
93
101
  // ═══════════════════════════════════════════════════════════════════════════
@@ -100,6 +108,7 @@ const JSON_TYPES = {
100
108
  /** Numeric keypad widget */
101
109
  NUMERIC_KEYPAD: "numeric-keypad"
102
110
  };
111
+ const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
103
112
  const VALIDATION_MODES = {
104
113
  /** Simple answer matching validation */
105
114
  SIMPLE: "simple",
@@ -112,6 +121,9 @@ const INPUT_TYPES = {
112
121
  /** Numeric input (digits only) */
113
122
  NUMERIC: "numeric"
114
123
  };
124
+ const NUMBER_LINE_STYLES = ["arrow-right", "arrow-left", "arrow-both", "line"];
125
+ const SIDE_PAD = 7;
126
+ const CSS_VAR_SIDE_PAD = "--nl-side-pad";
115
127
  const Newline = chevrotain.createToken({
116
128
  name: "Newline",
117
129
  pattern: /\r?\n/,
@@ -185,6 +197,14 @@ const NUMERIC_INPUT = chevrotain.createToken({
185
197
  name: "NUMERIC_INPUT",
186
198
  pattern: /NUMERIC_INPUT/
187
199
  });
200
+ const NUMBER_LINE = chevrotain.createToken({
201
+ name: "NUMBER_LINE",
202
+ pattern: /NUMBER_LINE/
203
+ });
204
+ const POSITION = chevrotain.createToken({
205
+ name: "POSITION",
206
+ pattern: /POSITION/
207
+ });
188
208
  const TABLE = chevrotain.createToken({
189
209
  name: "TABLE",
190
210
  pattern: /TABLE/
@@ -299,6 +319,8 @@ const allTokens = [
299
319
  WIDGET_ANSWER,
300
320
  W_N_I,
301
321
  NUMERIC_INPUT,
322
+ NUMBER_LINE,
323
+ POSITION,
302
324
  TABLE,
303
325
  ROW,
304
326
  CELL,
@@ -388,14 +410,14 @@ const MathExerciseLexer = new chevrotain.Lexer(allTokens, {
388
410
  positionTracking: "full"
389
411
  });
390
412
  function tokenize(input) {
391
- const result = MathExerciseLexer.tokenize(input);
413
+ const result = MathExerciseLexer.tokenize(input.replace(/\u00A0/g, " "));
392
414
  if (result.errors.length > 0) {
393
415
  throw new LexerError(result.errors);
394
416
  }
395
417
  return result.tokens;
396
418
  }
397
419
  function tokenizeSafe(input) {
398
- const result = MathExerciseLexer.tokenize(input);
420
+ const result = MathExerciseLexer.tokenize(input.replace(/\u00A0/g, " "));
399
421
  return {
400
422
  tokens: result.tokens,
401
423
  errors: result.errors
@@ -423,7 +445,9 @@ class MathExerciseParser extends chevrotain.CstParser {
423
445
  this.MANY3(() => {
424
446
  this.SUBRULE(this.tool);
425
447
  });
426
- this.SUBRULE(this.validation);
448
+ this.OPTION2(() => {
449
+ this.SUBRULE(this.validation);
450
+ });
427
451
  this.MANY4(() => {
428
452
  this.OR2([
429
453
  { ALT: () => this.CONSUME2(Newline) },
@@ -453,6 +477,10 @@ class MathExerciseParser extends chevrotain.CstParser {
453
477
  GATE: this.BACKTRACK(this.radioButtons),
454
478
  ALT: () => this.SUBRULE(this.radioButtons)
455
479
  },
480
+ {
481
+ GATE: this.BACKTRACK(this.numberLine),
482
+ ALT: () => this.SUBRULE(this.numberLine)
483
+ },
456
484
  { ALT: () => this.SUBRULE(this.line) }
457
485
  ]);
458
486
  });
@@ -557,6 +585,79 @@ class MathExerciseParser extends chevrotain.CstParser {
557
585
  ]);
558
586
  });
559
587
  });
588
+ this.numberLine = this.RULE("numberLine", () => {
589
+ this.MANY(() => {
590
+ this.OR2([
591
+ { ALT: () => this.CONSUME(Space) },
592
+ { ALT: () => this.CONSUME(Newline) }
593
+ ]);
594
+ });
595
+ this.CONSUME(NUMBER_LINE);
596
+ this.CONSUME(LBracket);
597
+ this.SUBRULE(this.parameterList);
598
+ this.CONSUME(RBracket);
599
+ this.MANY2(() => {
600
+ this.OR3([
601
+ { ALT: () => this.CONSUME2(Space) },
602
+ { ALT: () => this.CONSUME2(Newline) }
603
+ ]);
604
+ });
605
+ this.CONSUME(LBrace);
606
+ this.MANY3(() => {
607
+ this.OR4([
608
+ { ALT: () => this.CONSUME3(Space) },
609
+ { ALT: () => this.CONSUME3(Newline) }
610
+ ]);
611
+ });
612
+ this.MANY4(() => {
613
+ this.SUBRULE(this.position);
614
+ });
615
+ this.CONSUME(RBrace);
616
+ this.MANY5(() => {
617
+ this.OR5([
618
+ { ALT: () => this.CONSUME4(Newline) },
619
+ { ALT: () => this.CONSUME4(Space) }
620
+ ]);
621
+ });
622
+ });
623
+ this.position = this.RULE("position", () => {
624
+ this.MANY(() => {
625
+ this.OR([
626
+ { ALT: () => this.CONSUME(Space) },
627
+ { ALT: () => this.CONSUME(Newline) }
628
+ ]);
629
+ });
630
+ this.CONSUME(POSITION);
631
+ this.CONSUME(LBracket);
632
+ this.SUBRULE(this.parameterList);
633
+ this.CONSUME(RBracket);
634
+ this.MANY2(() => {
635
+ this.OR2([
636
+ { ALT: () => this.CONSUME2(Space) },
637
+ { ALT: () => this.CONSUME2(Newline) }
638
+ ]);
639
+ });
640
+ this.CONSUME(LBrace);
641
+ this.MANY3(() => {
642
+ this.OR3([
643
+ { ALT: () => this.CONSUME3(Space) },
644
+ { ALT: () => this.CONSUME3(Newline) }
645
+ ]);
646
+ });
647
+ this.MANY4(() => {
648
+ this.OR4([
649
+ { ALT: () => this.SUBRULE(this.inlineElement) },
650
+ { ALT: () => this.CONSUME4(Newline) }
651
+ ]);
652
+ });
653
+ this.CONSUME(RBrace);
654
+ this.MANY5(() => {
655
+ this.OR5([
656
+ { ALT: () => this.CONSUME5(Space) },
657
+ { ALT: () => this.CONSUME5(Newline) }
658
+ ]);
659
+ });
660
+ });
560
661
  this.line = this.RULE("line", () => {
561
662
  this.MANY(() => {
562
663
  this.CONSUME(Space);
@@ -919,6 +1020,71 @@ function isTextBlock(value) {
919
1020
  function isLineBlock(value) {
920
1021
  return LineBlockSchema.safeParse(value).success;
921
1022
  }
1023
+ const NumberLinePositionSchema = zod.z.object({
1024
+ type: zod.z.literal(AST_TYPES.NUMBER_LINE_POSITION),
1025
+ value: zod.z.number(),
1026
+ inlines: zod.z.array(InlineNodeSchema).min(1)
1027
+ });
1028
+ function createNumberLinePosition(value, inlines) {
1029
+ const hasRealContent = inlines.some((n) => n.type !== AST_TYPES.SPACE);
1030
+ if (!hasRealContent) {
1031
+ throw new Error(`POSITION[value=${value}]: content cannot be empty or whitespace only`);
1032
+ }
1033
+ const inputs = inlines.filter((i) => i.type === AST_TYPES.INLINE_INPUT);
1034
+ if (inputs.length > 1) {
1035
+ throw new Error(
1036
+ `POSITION[value=${value}]: only one input widget is allowed, got ${inputs.length}`
1037
+ );
1038
+ }
1039
+ return NumberLinePositionSchema.parse({
1040
+ type: AST_TYPES.NUMBER_LINE_POSITION,
1041
+ value,
1042
+ inlines
1043
+ });
1044
+ }
1045
+ const NumberLineBlockSchema = zod.z.object({
1046
+ type: zod.z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
1047
+ min: zod.z.number(),
1048
+ max: zod.z.number(),
1049
+ step: zod.z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
1050
+ minorTicks: zod.z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
1051
+ style: zod.z.enum(NUMBER_LINE_STYLES, {
1052
+ errorMap: () => ({
1053
+ message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
1054
+ })
1055
+ }).optional(),
1056
+ positions: zod.z.array(NumberLinePositionSchema)
1057
+ }).refine((data) => data.min < data.max, {
1058
+ message: "NUMBER_LINE: min must be less than max"
1059
+ }).superRefine((data, ctx) => {
1060
+ const seen = /* @__PURE__ */ new Set();
1061
+ for (const pos of data.positions) {
1062
+ if (pos.value < data.min || pos.value > data.max) {
1063
+ ctx.addIssue({
1064
+ code: zod.z.ZodIssueCode.custom,
1065
+ message: `NUMBER_LINE: POSITION value (${pos.value}) must be within [${data.min}, ${data.max}]`
1066
+ });
1067
+ }
1068
+ if (seen.has(pos.value)) {
1069
+ ctx.addIssue({
1070
+ code: zod.z.ZodIssueCode.custom,
1071
+ message: `NUMBER_LINE: duplicate POSITION value (${pos.value})`
1072
+ });
1073
+ }
1074
+ seen.add(pos.value);
1075
+ }
1076
+ });
1077
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1078
+ return NumberLineBlockSchema.parse({
1079
+ type: AST_TYPES.NUMBER_LINE_BLOCK,
1080
+ min,
1081
+ max,
1082
+ step,
1083
+ minorTicks,
1084
+ style,
1085
+ positions
1086
+ });
1087
+ }
922
1088
  const TableCellSchema = zod.z.object({
923
1089
  type: zod.z.literal(AST_TYPES.TABLE_CELL),
924
1090
  colspan: zod.z.number().int().positive().optional(),
@@ -1033,13 +1199,13 @@ function isExpressionValidation(value) {
1033
1199
  function isValidationNode(value) {
1034
1200
  return ValidationNodeSchema.safeParse(value).success;
1035
1201
  }
1036
- const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema]);
1202
+ const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema]);
1037
1203
  const ExerciseDocumentSchema = zod.z.object({
1038
1204
  type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
1039
1205
  question: TextBlockSchema.optional(),
1040
1206
  lines: zod.z.array(ContentBlockSchema),
1041
1207
  tools: zod.z.array(ToolNodeSchema).optional(),
1042
- validation: ValidationNodeSchema
1208
+ validation: ValidationNodeSchema.optional()
1043
1209
  });
1044
1210
  function createExerciseDocument(question, lines, validation, tools) {
1045
1211
  return ExerciseDocumentSchema.parse({
@@ -1066,7 +1232,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1066
1232
  const question = ctx.question ? this.visit(ctx.question) : void 0;
1067
1233
  const contentBlocks = ctx.contentBlock ? ctx.contentBlock.map((cb) => this.visit(cb)) : [];
1068
1234
  const tools = ctx.tool ? ctx.tool.map((t) => this.visit(t)) : [];
1069
- const validation = this.visit(ctx.validation);
1235
+ const validation = ctx.validation ? this.visit(ctx.validation) : void 0;
1070
1236
  return createExerciseDocument(question, contentBlocks, validation, tools);
1071
1237
  }
1072
1238
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1083,6 +1249,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1083
1249
  contentBlock(ctx) {
1084
1250
  if (ctx.table) return this.visit(ctx.table);
1085
1251
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
1252
+ if (ctx.numberLine) return this.visit(ctx.numberLine);
1086
1253
  if (ctx.line) return this.visit(ctx.line);
1087
1254
  throw new Error("Unknown content block");
1088
1255
  }
@@ -1113,6 +1280,31 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1113
1280
  return createTableCell(inlines, colspan);
1114
1281
  }
1115
1282
  // ═══════════════════════════════════════════════════════════════════════════
1283
+ // Number Line Block
1284
+ // ═══════════════════════════════════════════════════════════════════════════
1285
+ numberLine(ctx) {
1286
+ const params = this.visit(ctx.parameterList);
1287
+ if (params.min === void 0 || params.max === void 0) {
1288
+ throw new Error("NUMBER_LINE requires parameters: min and max");
1289
+ }
1290
+ const min = Number(params.min);
1291
+ const max = Number(params.max);
1292
+ const step = params.step !== void 0 ? Number(params.step) : void 0;
1293
+ const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1294
+ const style = params.style;
1295
+ const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1296
+ return createNumberLineBlock(min, max, positions, step, minorTicks, style);
1297
+ }
1298
+ position(ctx) {
1299
+ const params = this.visit(ctx.parameterList);
1300
+ if (params.value === void 0) {
1301
+ throw new Error("POSITION requires parameter: value");
1302
+ }
1303
+ const value = Number(params.value);
1304
+ const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
1305
+ return createNumberLinePosition(value, inlines);
1306
+ }
1307
+ // ═══════════════════════════════════════════════════════════════════════════
1116
1308
  // Line Block
1117
1309
  // ═══════════════════════════════════════════════════════════════════════════
1118
1310
  line(ctx) {
@@ -1413,7 +1605,8 @@ const LayoutItemSchema = zod.z.lazy(
1413
1605
  LinebreakSchema,
1414
1606
  GroupItemSchema,
1415
1607
  OperatorItemSchema,
1416
- TableItemSchema
1608
+ TableItemSchema,
1609
+ NumberLineItemSchema
1417
1610
  ])
1418
1611
  );
1419
1612
  const GroupItemSchema = zod.z.object({
@@ -1438,6 +1631,22 @@ const TableItemSchema = zod.z.object({
1438
1631
  cols: zod.z.number().int().positive(),
1439
1632
  rows: zod.z.array(TableRowItemSchema)
1440
1633
  });
1634
+ const NumberLinePositionItemSchema = zod.z.object({
1635
+ id: zod.z.string(),
1636
+ type: zod.z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
1637
+ value: zod.z.number(),
1638
+ content: zod.z.array(zod.z.lazy(() => LayoutItemSchema))
1639
+ });
1640
+ const NumberLineItemSchema = zod.z.object({
1641
+ id: zod.z.string(),
1642
+ type: zod.z.literal(JSON_TYPES.NUMBER_LINE),
1643
+ min: zod.z.number(),
1644
+ max: zod.z.number(),
1645
+ step: zod.z.number().positive(),
1646
+ minorTicks: zod.z.number().int().nonnegative().optional(),
1647
+ style: zod.z.enum(NUMBER_LINE_STYLES).optional(),
1648
+ positions: zod.z.array(NumberLinePositionItemSchema)
1649
+ });
1441
1650
  const StringInputJSONSchema = zod.z.object({
1442
1651
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
1443
1652
  inputType: zod.z.literal(INPUT_TYPES.STRING),
@@ -1488,7 +1697,7 @@ const ExerciseJSONSchema = zod.z.object({
1488
1697
  id: zod.z.string().optional(),
1489
1698
  layout: zod.z.array(LayoutItemSchema),
1490
1699
  widgets: zod.z.record(WidgetJSONSchema),
1491
- validation: ValidationJSONSchema,
1700
+ validation: ValidationJSONSchema.optional(),
1492
1701
  meta: ExerciseMetaSchema.optional()
1493
1702
  });
1494
1703
  function validateExerciseJSON(data) {
@@ -1509,7 +1718,7 @@ function isQuestionText(item) {
1509
1718
  function isTextItem$1(item) {
1510
1719
  return item.type === JSON_TYPES.TEXT;
1511
1720
  }
1512
- function isWidgetRef(item) {
1721
+ function isWidgetRef$1(item) {
1513
1722
  return item.type === JSON_TYPES.WIDGET_REF;
1514
1723
  }
1515
1724
  function isHighlightedText(item) {
@@ -1578,6 +1787,8 @@ function toJSON(ast) {
1578
1787
  for (const block of ast.lines) {
1579
1788
  if (block.type === AST_TYPES.TABLE_BLOCK) {
1580
1789
  layout.push(transformTable(block, idGenerator));
1790
+ } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
1791
+ layout.push(transformNumberLine(block, idGenerator));
1581
1792
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
1582
1793
  const radioBlock = block;
1583
1794
  layout.push({
@@ -1643,13 +1854,12 @@ function toJSON(ast) {
1643
1854
  }
1644
1855
  }
1645
1856
  }
1646
- const validation = transformValidation(ast.validation, answers, widgets);
1647
1857
  return {
1648
1858
  version: "2.0",
1649
1859
  // Natural DSL version
1650
1860
  layout,
1651
1861
  widgets,
1652
- validation
1862
+ ...ast.validation ? { validation: transformValidation(ast.validation, answers, widgets) } : {}
1653
1863
  };
1654
1864
  }
1655
1865
  function collectAllInlines(blocks) {
@@ -1663,10 +1873,36 @@ function collectAllInlines(blocks) {
1663
1873
  result.push(...cell.inlines);
1664
1874
  }
1665
1875
  }
1876
+ } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
1877
+ for (const position of block.positions) {
1878
+ result.push(...position.inlines);
1879
+ }
1666
1880
  }
1667
1881
  }
1668
1882
  return result;
1669
1883
  }
1884
+ function transformNumberLine(numberLine, idGenerator) {
1885
+ const positions = numberLine.positions.map((pos) => {
1886
+ const content = pos.inlines.map((inline) => transformInline(inline, idGenerator));
1887
+ return {
1888
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_POSITION),
1889
+ type: JSON_TYPES.NUMBER_LINE_POSITION,
1890
+ value: pos.value,
1891
+ content
1892
+ };
1893
+ });
1894
+ const item = {
1895
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
1896
+ type: JSON_TYPES.NUMBER_LINE,
1897
+ min: numberLine.min,
1898
+ max: numberLine.max,
1899
+ step: numberLine.step,
1900
+ positions
1901
+ };
1902
+ if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
1903
+ if (numberLine.style !== void 0) item.style = numberLine.style;
1904
+ return item;
1905
+ }
1670
1906
  function transformTable(table, idGenerator) {
1671
1907
  const rows = table.rows.map((row) => {
1672
1908
  const cells = row.cells.map((cell) => {
@@ -2094,11 +2330,18 @@ function findValidCombination(expression, ranges) {
2094
2330
  return null;
2095
2331
  }
2096
2332
  function validate(exercise, userInputs) {
2097
- switch (exercise.validation.mode) {
2333
+ if (!exercise.validation) {
2334
+ return {
2335
+ isCorrect: true,
2336
+ details: {}
2337
+ };
2338
+ }
2339
+ const validatedExercise = exercise;
2340
+ switch (validatedExercise.validation.mode) {
2098
2341
  case VALIDATION_MODES.SIMPLE:
2099
- return validateSimple(exercise, userInputs);
2342
+ return validateSimple(validatedExercise, userInputs);
2100
2343
  case VALIDATION_MODES.EXPRESSION:
2101
- return validateExpression(exercise, userInputs);
2344
+ return validateExpression(validatedExercise, userInputs);
2102
2345
  default:
2103
2346
  return {
2104
2347
  isCorrect: false,
@@ -2109,6 +2352,9 @@ function validate(exercise, userInputs) {
2109
2352
  }
2110
2353
  function getMissingInputs(exercise, userInputs) {
2111
2354
  const missing = [];
2355
+ if (!exercise.validation) {
2356
+ return missing;
2357
+ }
2112
2358
  if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
2113
2359
  for (const widgetId of Object.keys(exercise.validation.answers)) {
2114
2360
  if (!(widgetId in userInputs) || userInputs[widgetId] === "") {
@@ -2148,6 +2394,8 @@ function validateInputFormats(exercise, userInputs) {
2148
2394
  const DragItemTypeSchema = zod.z.enum([
2149
2395
  "drag-option",
2150
2396
  // Draggable answer options
2397
+ "placed-chip",
2398
+ // Chip already placed on a number-line axis (re-drag)
2151
2399
  "ruler-handle",
2152
2400
  // Future: ruler endpoints
2153
2401
  "shape",
@@ -5859,7 +6107,8 @@ const ExerciseDndProvider = ({
5859
6107
  }, [dropHandlers]);
5860
6108
  const handleDragStart = (event) => {
5861
6109
  const { active } = event;
5862
- const item = active.data.current;
6110
+ const raw = active.data.current;
6111
+ const item = raw?.type === "placed-chip" ? { id: String(active.id), type: "placed-chip", data: { label: raw.label } } : raw;
5863
6112
  setActiveItem(item);
5864
6113
  };
5865
6114
  const handleDragEnd = (event) => {
@@ -5910,6 +6159,10 @@ const DragOverlayContent = ({ item }) => {
5910
6159
  const data = item.data;
5911
6160
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-overlay-option", children: data.label });
5912
6161
  }
6162
+ case "placed-chip": {
6163
+ const label = item.data?.label ?? "";
6164
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-overlay-option", children: label });
6165
+ }
5913
6166
  default:
5914
6167
  return null;
5915
6168
  }
@@ -6368,6 +6621,334 @@ const WidgetRenderer = ({
6368
6621
  }
6369
6622
  return null;
6370
6623
  };
6624
+ const cleanFloat = (n) => parseFloat(n.toFixed(10));
6625
+ const toPercent = (value, min, max) => {
6626
+ const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
6627
+ return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
6628
+ };
6629
+ const pixelToValue = (chipCenterX, axisLeft, axisWidth, min, max) => {
6630
+ const relX = (chipCenterX - axisLeft) / axisWidth;
6631
+ const fraction = (relX - SIDE_PAD / 100) / (1 - 2 * SIDE_PAD / 100);
6632
+ const clamped = Math.max(0, Math.min(1, fraction));
6633
+ return min + clamped * (max - min);
6634
+ };
6635
+ const getMajorTicks = (min, max, step) => {
6636
+ if (step <= 0 || max <= min) return [min, max];
6637
+ const count = Math.floor((max - min) / step);
6638
+ const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
6639
+ const lastTick = ticks[ticks.length - 1];
6640
+ if (Math.abs(lastTick - max) > 1e-9) ticks.push(max);
6641
+ return ticks;
6642
+ };
6643
+ const getMinorTickValues = (majorTicks, minorTicks) => {
6644
+ if (minorTicks <= 0) return [];
6645
+ const result = [];
6646
+ majorTicks.slice(0, -1).forEach((tick, idx) => {
6647
+ const next = majorTicks[idx + 1];
6648
+ const interval = next - tick;
6649
+ for (let j = 1; j <= minorTicks; j++) {
6650
+ result.push(cleanFloat(tick + j / (minorTicks + 1) * interval));
6651
+ }
6652
+ });
6653
+ return result;
6654
+ };
6655
+ const getSnapTicks = (majorTicks, minorTicks) => [
6656
+ ...majorTicks,
6657
+ ...getMinorTickValues(majorTicks, minorTicks)
6658
+ ];
6659
+ const snapToNearest = (raw, ticks) => {
6660
+ if (ticks.length === 0) return cleanFloat(raw);
6661
+ const snapped = ticks.reduce(
6662
+ (best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best
6663
+ );
6664
+ return cleanFloat(snapped);
6665
+ };
6666
+ const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
6667
+ const getPositionWidgetId = (content) => {
6668
+ for (const item of content) {
6669
+ if (isWidgetRef(item)) return item.widgetId;
6670
+ }
6671
+ return null;
6672
+ };
6673
+ const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
6674
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
6675
+ id: widgetId,
6676
+ data: { type: "placed-chip", widgetId, label }
6677
+ });
6678
+ return /* @__PURE__ */ jsxRuntime.jsx(
6679
+ "div",
6680
+ {
6681
+ className: "number-line-placed-chip",
6682
+ style: { left: `${leftPercent}%`, opacity: isDragging ? 0.3 : 1 },
6683
+ children: /* @__PURE__ */ jsxRuntime.jsx(
6684
+ "button",
6685
+ {
6686
+ ref: setNodeRef,
6687
+ className: "number-line-placed-chip-btn",
6688
+ title: "Drag to move · Click to remove",
6689
+ onClick: onRemove,
6690
+ ...listeners,
6691
+ ...attributes,
6692
+ children: label
6693
+ }
6694
+ )
6695
+ }
6696
+ );
6697
+ };
6698
+ const FreeDragAxis = ({
6699
+ axisDropId,
6700
+ min,
6701
+ max,
6702
+ minorTicks,
6703
+ majorTicks,
6704
+ showArrowLeft,
6705
+ showArrowRight,
6706
+ positions,
6707
+ values,
6708
+ onChange
6709
+ }) => {
6710
+ const axisRef = React.useRef(null);
6711
+ const snapTicks = React.useMemo(
6712
+ () => getSnapTicks(majorTicks, minorTicks),
6713
+ [majorTicks, minorTicks]
6714
+ );
6715
+ const [dropLabels, setDropLabels] = React.useState(/* @__PURE__ */ new Map());
6716
+ const [dropSources, setDropSources] = React.useState(/* @__PURE__ */ new Map());
6717
+ React.useEffect(() => {
6718
+ setDropLabels((prev) => {
6719
+ let changed = false;
6720
+ const next = new Map(prev);
6721
+ for (const [wid] of next) {
6722
+ if (!values[wid]) {
6723
+ next.delete(wid);
6724
+ changed = true;
6725
+ }
6726
+ }
6727
+ return changed ? next : prev;
6728
+ });
6729
+ setDropSources((prev) => {
6730
+ let changed = false;
6731
+ const next = new Map(prev);
6732
+ for (const [wid] of next) {
6733
+ if (!values[wid]) {
6734
+ next.delete(wid);
6735
+ changed = true;
6736
+ }
6737
+ }
6738
+ return changed ? next : prev;
6739
+ });
6740
+ }, [values]);
6741
+ const { setNodeRef, isOver } = useDroppable({ id: axisDropId });
6742
+ const mergedRef = React.useCallback((node) => {
6743
+ axisRef.current = node;
6744
+ setNodeRef(node);
6745
+ }, [setNodeRef]);
6746
+ const snapFromEvent = React.useCallback((translated) => {
6747
+ if (!axisRef.current) return null;
6748
+ const axisRect = axisRef.current.getBoundingClientRect();
6749
+ const chipCenterX = translated.left + translated.width / 2;
6750
+ const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
6751
+ return snapToNearest(raw, snapTicks);
6752
+ }, [min, max, snapTicks]);
6753
+ useDndMonitor({
6754
+ onDragEnd(event) {
6755
+ if (!event.over || event.over.id !== axisDropId) return;
6756
+ if (!axisRef.current) return;
6757
+ const translated = event.active.rect.current.translated;
6758
+ if (!translated) return;
6759
+ const placed = snapFromEvent(translated);
6760
+ if (placed === null) return;
6761
+ const data = event.active.data.current;
6762
+ if (data?.type === "placed-chip") {
6763
+ const widgetId = data.widgetId;
6764
+ const label = data.label;
6765
+ const originalSourceId = dropSources.get(widgetId) ?? widgetId;
6766
+ onChange(widgetId, String(placed), true, originalSourceId);
6767
+ setDropLabels((prev) => new Map(prev).set(widgetId, label));
6768
+ return;
6769
+ }
6770
+ const draggedItem = event.active.data.current;
6771
+ if (draggedItem?.type !== "drag-option") return;
6772
+ const targetPos = positions.find((pos) => {
6773
+ const wid = getPositionWidgetId(pos.content);
6774
+ return wid && !values[wid];
6775
+ });
6776
+ if (!targetPos) return;
6777
+ const targetWidgetId = getPositionWidgetId(targetPos.content);
6778
+ if (!targetWidgetId) return;
6779
+ const chipLabel = draggedItem.data.value;
6780
+ const sourceOptId = String(event.active.id);
6781
+ onChange(targetWidgetId, String(placed), true, sourceOptId);
6782
+ setDropLabels((prev) => new Map(prev).set(targetWidgetId, chipLabel));
6783
+ setDropSources((prev) => new Map(prev).set(targetWidgetId, sourceOptId));
6784
+ }
6785
+ });
6786
+ return /* @__PURE__ */ jsxRuntime.jsxs(
6787
+ "div",
6788
+ {
6789
+ ref: mergedRef,
6790
+ className: `number-line-axis-row nl-axis-drag-mode${isOver ? " nl-axis-drag-over" : ""}`,
6791
+ children: [
6792
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track" }),
6793
+ showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left" }),
6794
+ showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right" }),
6795
+ getMinorTickValues(majorTicks, minorTicks).map((v) => /* @__PURE__ */ jsxRuntime.jsx(
6796
+ "div",
6797
+ {
6798
+ className: "number-line-minor-tick",
6799
+ style: { left: `${toPercent(v, min, max)}%` }
6800
+ },
6801
+ `minor-${v}`
6802
+ )),
6803
+ majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
6804
+ "div",
6805
+ {
6806
+ className: "number-line-major-tick",
6807
+ style: { left: `${toPercent(tick, min, max)}%` }
6808
+ },
6809
+ `major-${tick}`
6810
+ )),
6811
+ positions.map((pos) => {
6812
+ const wid = getPositionWidgetId(pos.content);
6813
+ const dropPos = wid ? parseFloat(values[wid] ?? "") : NaN;
6814
+ const label = wid ? dropLabels.get(wid) : void 0;
6815
+ if (!wid || isNaN(dropPos) || !label) return null;
6816
+ return /* @__PURE__ */ jsxRuntime.jsx(
6817
+ PlacedChip,
6818
+ {
6819
+ widgetId: wid,
6820
+ label,
6821
+ leftPercent: toPercent(dropPos, min, max),
6822
+ onRemove: () => {
6823
+ const sourceId = dropSources.get(wid);
6824
+ onChange(wid, "", false, sourceId);
6825
+ setDropLabels((prev) => {
6826
+ const m = new Map(prev);
6827
+ m.delete(wid);
6828
+ return m;
6829
+ });
6830
+ setDropSources((prev) => {
6831
+ const m = new Map(prev);
6832
+ m.delete(wid);
6833
+ return m;
6834
+ });
6835
+ }
6836
+ },
6837
+ wid
6838
+ );
6839
+ })
6840
+ ]
6841
+ }
6842
+ );
6843
+ };
6844
+ const NumberLineRenderer = ({
6845
+ item,
6846
+ widgets,
6847
+ values,
6848
+ droppedInputs,
6849
+ onChange,
6850
+ onInputFocus
6851
+ }) => {
6852
+ const { min, max, step, minorTicks = 0, style = "arrow-right", positions } = item;
6853
+ const majorTicks = React.useMemo(() => getMajorTicks(min, max, step), [min, max, step]);
6854
+ const minorValues = React.useMemo(() => getMinorTickValues(majorTicks, minorTicks), [majorTicks, minorTicks]);
6855
+ const showArrowRight = style === "arrow-right" || style === "arrow-both";
6856
+ const showArrowLeft = style === "arrow-left" || style === "arrow-both";
6857
+ const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
6858
+ const inputTickSet = React.useMemo(() => {
6859
+ if (isDragMode) return /* @__PURE__ */ new Set();
6860
+ return new Set(
6861
+ positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
6862
+ );
6863
+ }, [isDragMode, positions]);
6864
+ return /* @__PURE__ */ jsxRuntime.jsxs(
6865
+ "div",
6866
+ {
6867
+ className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
6868
+ style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
6869
+ role: "img",
6870
+ "aria-label": `Number line from ${min} to ${max}`,
6871
+ children: [
6872
+ !isDragMode && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
6873
+ "div",
6874
+ {
6875
+ className: "number-line-position-content",
6876
+ style: { left: `${toPercent(pos.value, min, max)}%` },
6877
+ children: pos.content.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
6878
+ LayoutRenderer,
6879
+ {
6880
+ item: subItem,
6881
+ widgets,
6882
+ values,
6883
+ droppedInputs,
6884
+ onChange,
6885
+ onInputFocus
6886
+ },
6887
+ subItem.id
6888
+ ))
6889
+ },
6890
+ pos.id
6891
+ )) }),
6892
+ isDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
6893
+ FreeDragAxis,
6894
+ {
6895
+ axisDropId: `${item.id}--nl-axis`,
6896
+ min,
6897
+ max,
6898
+ minorTicks,
6899
+ majorTicks,
6900
+ showArrowLeft,
6901
+ showArrowRight,
6902
+ positions,
6903
+ values,
6904
+ onChange
6905
+ }
6906
+ ) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
6907
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
6908
+ showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
6909
+ showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
6910
+ minorValues.map((v) => /* @__PURE__ */ jsxRuntime.jsx(
6911
+ "div",
6912
+ {
6913
+ className: "number-line-minor-tick",
6914
+ "aria-hidden": "true",
6915
+ style: { left: `${toPercent(v, min, max)}%` }
6916
+ },
6917
+ `minor-${v}`
6918
+ )),
6919
+ majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
6920
+ "div",
6921
+ {
6922
+ className: "number-line-major-tick",
6923
+ style: { left: `${toPercent(tick, min, max)}%` }
6924
+ },
6925
+ `major-${tick}`
6926
+ )),
6927
+ positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
6928
+ "div",
6929
+ {
6930
+ className: "number-line-position-dot",
6931
+ style: { left: `${toPercent(pos.value, min, max)}%` }
6932
+ },
6933
+ `dot-${pos.id}`
6934
+ ))
6935
+ ] }),
6936
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
6937
+ const hasInputAtTick = inputTickSet.has(tick);
6938
+ return /* @__PURE__ */ jsxRuntime.jsx(
6939
+ "span",
6940
+ {
6941
+ className: "number-line-tick-label",
6942
+ style: { left: `${toPercent(tick, min, max)}%`, visibility: hasInputAtTick ? "hidden" : "visible" },
6943
+ children: tick
6944
+ },
6945
+ `lbl-${tick}`
6946
+ );
6947
+ }) })
6948
+ ]
6949
+ }
6950
+ );
6951
+ };
6371
6952
  function isArabic(text) {
6372
6953
  return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
6373
6954
  }
@@ -6535,6 +7116,18 @@ const LayoutRenderer = ({
6535
7116
  },
6536
7117
  cell.id
6537
7118
  )) }, row.id)) }) }) });
7119
+ case JSON_TYPES.NUMBER_LINE:
7120
+ return /* @__PURE__ */ jsxRuntime.jsx(
7121
+ NumberLineRenderer,
7122
+ {
7123
+ item,
7124
+ widgets,
7125
+ values,
7126
+ droppedInputs,
7127
+ onChange,
7128
+ onInputFocus
7129
+ }
7130
+ );
6538
7131
  default:
6539
7132
  return null;
6540
7133
  }
@@ -6622,6 +7215,20 @@ const ExerciseRenderer = React.forwardRef(({
6622
7215
  });
6623
7216
  continue;
6624
7217
  }
7218
+ if (item.type === JSON_TYPES.NUMBER_LINE) {
7219
+ if (currentGroup.length > 0) {
7220
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
7221
+ const subGroups = groupLineItems(currentGroup);
7222
+ groups.push({ items: currentGroup, hasArabic, subGroups });
7223
+ currentGroup = [];
7224
+ }
7225
+ groups.push({
7226
+ items: [item],
7227
+ hasArabic: false,
7228
+ subGroups: []
7229
+ });
7230
+ continue;
7231
+ }
6625
7232
  if (item.type === JSON_TYPES.LINEBREAK) {
6626
7233
  if (currentGroup.length > 0) {
6627
7234
  const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
@@ -6872,7 +7479,7 @@ exports.isTextItem = isTextItem$1;
6872
7479
  exports.isToolNode = isToolNode;
6873
7480
  exports.isValidExpression = isValidExpression;
6874
7481
  exports.isValidationNode = isValidationNode;
6875
- exports.isWidgetRef = isWidgetRef;
7482
+ exports.isWidgetRef = isWidgetRef$1;
6876
7483
  exports.parse = parse;
6877
7484
  exports.parseSafe = parseSafe;
6878
7485
  exports.renderExercise = renderExercise;