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