@mathscan/math-exercise-engine 1.0.8 → 1.0.9

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
@@ -451,6 +473,10 @@ class MathExerciseParser extends CstParser {
451
473
  GATE: this.BACKTRACK(this.radioButtons),
452
474
  ALT: () => this.SUBRULE(this.radioButtons)
453
475
  },
476
+ {
477
+ GATE: this.BACKTRACK(this.numberLine),
478
+ ALT: () => this.SUBRULE(this.numberLine)
479
+ },
454
480
  { ALT: () => this.SUBRULE(this.line) }
455
481
  ]);
456
482
  });
@@ -555,6 +581,79 @@ class MathExerciseParser extends CstParser {
555
581
  ]);
556
582
  });
557
583
  });
584
+ this.numberLine = this.RULE("numberLine", () => {
585
+ this.MANY(() => {
586
+ this.OR2([
587
+ { ALT: () => this.CONSUME(Space) },
588
+ { ALT: () => this.CONSUME(Newline) }
589
+ ]);
590
+ });
591
+ this.CONSUME(NUMBER_LINE);
592
+ this.CONSUME(LBracket);
593
+ this.SUBRULE(this.parameterList);
594
+ this.CONSUME(RBracket);
595
+ this.MANY2(() => {
596
+ this.OR3([
597
+ { ALT: () => this.CONSUME2(Space) },
598
+ { ALT: () => this.CONSUME2(Newline) }
599
+ ]);
600
+ });
601
+ this.CONSUME(LBrace);
602
+ this.MANY3(() => {
603
+ this.OR4([
604
+ { ALT: () => this.CONSUME3(Space) },
605
+ { ALT: () => this.CONSUME3(Newline) }
606
+ ]);
607
+ });
608
+ this.MANY4(() => {
609
+ this.SUBRULE(this.position);
610
+ });
611
+ this.CONSUME(RBrace);
612
+ this.MANY5(() => {
613
+ this.OR5([
614
+ { ALT: () => this.CONSUME4(Newline) },
615
+ { ALT: () => this.CONSUME4(Space) }
616
+ ]);
617
+ });
618
+ });
619
+ this.position = this.RULE("position", () => {
620
+ this.MANY(() => {
621
+ this.OR([
622
+ { ALT: () => this.CONSUME(Space) },
623
+ { ALT: () => this.CONSUME(Newline) }
624
+ ]);
625
+ });
626
+ this.CONSUME(POSITION);
627
+ this.CONSUME(LBracket);
628
+ this.SUBRULE(this.parameterList);
629
+ this.CONSUME(RBracket);
630
+ this.MANY2(() => {
631
+ this.OR2([
632
+ { ALT: () => this.CONSUME2(Space) },
633
+ { ALT: () => this.CONSUME2(Newline) }
634
+ ]);
635
+ });
636
+ this.CONSUME(LBrace);
637
+ this.MANY3(() => {
638
+ this.OR3([
639
+ { ALT: () => this.CONSUME3(Space) },
640
+ { ALT: () => this.CONSUME3(Newline) }
641
+ ]);
642
+ });
643
+ this.MANY4(() => {
644
+ this.OR4([
645
+ { ALT: () => this.SUBRULE(this.inlineElement) },
646
+ { ALT: () => this.CONSUME4(Newline) }
647
+ ]);
648
+ });
649
+ this.CONSUME(RBrace);
650
+ this.MANY5(() => {
651
+ this.OR5([
652
+ { ALT: () => this.CONSUME5(Space) },
653
+ { ALT: () => this.CONSUME5(Newline) }
654
+ ]);
655
+ });
656
+ });
558
657
  this.line = this.RULE("line", () => {
559
658
  this.MANY(() => {
560
659
  this.CONSUME(Space);
@@ -917,6 +1016,71 @@ function isTextBlock(value) {
917
1016
  function isLineBlock(value) {
918
1017
  return LineBlockSchema.safeParse(value).success;
919
1018
  }
1019
+ const NumberLinePositionSchema = z.object({
1020
+ type: z.literal(AST_TYPES.NUMBER_LINE_POSITION),
1021
+ value: z.number(),
1022
+ inlines: z.array(InlineNodeSchema).min(1)
1023
+ });
1024
+ function createNumberLinePosition(value, inlines) {
1025
+ const hasRealContent = inlines.some((n) => n.type !== AST_TYPES.SPACE);
1026
+ if (!hasRealContent) {
1027
+ throw new Error(`POSITION[value=${value}]: content cannot be empty or whitespace only`);
1028
+ }
1029
+ const inputs = inlines.filter((i) => i.type === AST_TYPES.INLINE_INPUT);
1030
+ if (inputs.length > 1) {
1031
+ throw new Error(
1032
+ `POSITION[value=${value}]: only one input widget is allowed, got ${inputs.length}`
1033
+ );
1034
+ }
1035
+ return NumberLinePositionSchema.parse({
1036
+ type: AST_TYPES.NUMBER_LINE_POSITION,
1037
+ value,
1038
+ inlines
1039
+ });
1040
+ }
1041
+ const NumberLineBlockSchema = z.object({
1042
+ type: z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
1043
+ min: z.number(),
1044
+ max: z.number(),
1045
+ step: z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
1046
+ minorTicks: z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
1047
+ style: z.enum(NUMBER_LINE_STYLES, {
1048
+ errorMap: () => ({
1049
+ message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
1050
+ })
1051
+ }).optional(),
1052
+ positions: z.array(NumberLinePositionSchema)
1053
+ }).refine((data) => data.min < data.max, {
1054
+ message: "NUMBER_LINE: min must be less than max"
1055
+ }).superRefine((data, ctx) => {
1056
+ const seen = /* @__PURE__ */ new Set();
1057
+ for (const pos of data.positions) {
1058
+ if (pos.value < data.min || pos.value > data.max) {
1059
+ ctx.addIssue({
1060
+ code: z.ZodIssueCode.custom,
1061
+ message: `NUMBER_LINE: POSITION value (${pos.value}) must be within [${data.min}, ${data.max}]`
1062
+ });
1063
+ }
1064
+ if (seen.has(pos.value)) {
1065
+ ctx.addIssue({
1066
+ code: z.ZodIssueCode.custom,
1067
+ message: `NUMBER_LINE: duplicate POSITION value (${pos.value})`
1068
+ });
1069
+ }
1070
+ seen.add(pos.value);
1071
+ }
1072
+ });
1073
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1074
+ return NumberLineBlockSchema.parse({
1075
+ type: AST_TYPES.NUMBER_LINE_BLOCK,
1076
+ min,
1077
+ max,
1078
+ step,
1079
+ minorTicks,
1080
+ style,
1081
+ positions
1082
+ });
1083
+ }
920
1084
  const TableCellSchema = z.object({
921
1085
  type: z.literal(AST_TYPES.TABLE_CELL),
922
1086
  colspan: z.number().int().positive().optional(),
@@ -1031,7 +1195,7 @@ function isExpressionValidation(value) {
1031
1195
  function isValidationNode(value) {
1032
1196
  return ValidationNodeSchema.safeParse(value).success;
1033
1197
  }
1034
- const ContentBlockSchema = z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema]);
1198
+ const ContentBlockSchema = z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema]);
1035
1199
  const ExerciseDocumentSchema = z.object({
1036
1200
  type: z.literal(AST_TYPES.EXERCISE_DOCUMENT),
1037
1201
  question: TextBlockSchema.optional(),
@@ -1081,6 +1245,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1081
1245
  contentBlock(ctx) {
1082
1246
  if (ctx.table) return this.visit(ctx.table);
1083
1247
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
1248
+ if (ctx.numberLine) return this.visit(ctx.numberLine);
1084
1249
  if (ctx.line) return this.visit(ctx.line);
1085
1250
  throw new Error("Unknown content block");
1086
1251
  }
@@ -1111,6 +1276,31 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1111
1276
  return createTableCell(inlines, colspan);
1112
1277
  }
1113
1278
  // ═══════════════════════════════════════════════════════════════════════════
1279
+ // Number Line Block
1280
+ // ═══════════════════════════════════════════════════════════════════════════
1281
+ numberLine(ctx) {
1282
+ const params = this.visit(ctx.parameterList);
1283
+ if (params.min === void 0 || params.max === void 0) {
1284
+ throw new Error("NUMBER_LINE requires parameters: min and max");
1285
+ }
1286
+ const min = Number(params.min);
1287
+ const max = Number(params.max);
1288
+ const step = params.step !== void 0 ? Number(params.step) : void 0;
1289
+ const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1290
+ const style = params.style;
1291
+ const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1292
+ return createNumberLineBlock(min, max, positions, step, minorTicks, style);
1293
+ }
1294
+ position(ctx) {
1295
+ const params = this.visit(ctx.parameterList);
1296
+ if (params.value === void 0) {
1297
+ throw new Error("POSITION requires parameter: value");
1298
+ }
1299
+ const value = Number(params.value);
1300
+ const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
1301
+ return createNumberLinePosition(value, inlines);
1302
+ }
1303
+ // ═══════════════════════════════════════════════════════════════════════════
1114
1304
  // Line Block
1115
1305
  // ═══════════════════════════════════════════════════════════════════════════
1116
1306
  line(ctx) {
@@ -1411,7 +1601,8 @@ const LayoutItemSchema = z.lazy(
1411
1601
  LinebreakSchema,
1412
1602
  GroupItemSchema,
1413
1603
  OperatorItemSchema,
1414
- TableItemSchema
1604
+ TableItemSchema,
1605
+ NumberLineItemSchema
1415
1606
  ])
1416
1607
  );
1417
1608
  const GroupItemSchema = z.object({
@@ -1436,6 +1627,22 @@ const TableItemSchema = z.object({
1436
1627
  cols: z.number().int().positive(),
1437
1628
  rows: z.array(TableRowItemSchema)
1438
1629
  });
1630
+ const NumberLinePositionItemSchema = z.object({
1631
+ id: z.string(),
1632
+ type: z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
1633
+ value: z.number(),
1634
+ content: z.array(z.lazy(() => LayoutItemSchema))
1635
+ });
1636
+ const NumberLineItemSchema = z.object({
1637
+ id: z.string(),
1638
+ type: z.literal(JSON_TYPES.NUMBER_LINE),
1639
+ min: z.number(),
1640
+ max: z.number(),
1641
+ step: z.number().positive(),
1642
+ minorTicks: z.number().int().nonnegative().optional(),
1643
+ style: z.enum(NUMBER_LINE_STYLES).optional(),
1644
+ positions: z.array(NumberLinePositionItemSchema)
1645
+ });
1439
1646
  const StringInputJSONSchema = z.object({
1440
1647
  type: z.literal(JSON_TYPES.INLINE_INPUT),
1441
1648
  inputType: z.literal(INPUT_TYPES.STRING),
@@ -1507,7 +1714,7 @@ function isQuestionText(item) {
1507
1714
  function isTextItem$1(item) {
1508
1715
  return item.type === JSON_TYPES.TEXT;
1509
1716
  }
1510
- function isWidgetRef(item) {
1717
+ function isWidgetRef$1(item) {
1511
1718
  return item.type === JSON_TYPES.WIDGET_REF;
1512
1719
  }
1513
1720
  function isHighlightedText(item) {
@@ -1576,6 +1783,8 @@ function toJSON(ast) {
1576
1783
  for (const block of ast.lines) {
1577
1784
  if (block.type === AST_TYPES.TABLE_BLOCK) {
1578
1785
  layout.push(transformTable(block, idGenerator));
1786
+ } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
1787
+ layout.push(transformNumberLine(block, idGenerator));
1579
1788
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
1580
1789
  const radioBlock = block;
1581
1790
  layout.push({
@@ -1661,10 +1870,36 @@ function collectAllInlines(blocks) {
1661
1870
  result.push(...cell.inlines);
1662
1871
  }
1663
1872
  }
1873
+ } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
1874
+ for (const position of block.positions) {
1875
+ result.push(...position.inlines);
1876
+ }
1664
1877
  }
1665
1878
  }
1666
1879
  return result;
1667
1880
  }
1881
+ function transformNumberLine(numberLine, idGenerator) {
1882
+ const positions = numberLine.positions.map((pos) => {
1883
+ const content = pos.inlines.map((inline) => transformInline(inline, idGenerator));
1884
+ return {
1885
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_POSITION),
1886
+ type: JSON_TYPES.NUMBER_LINE_POSITION,
1887
+ value: pos.value,
1888
+ content
1889
+ };
1890
+ });
1891
+ const item = {
1892
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
1893
+ type: JSON_TYPES.NUMBER_LINE,
1894
+ min: numberLine.min,
1895
+ max: numberLine.max,
1896
+ step: numberLine.step,
1897
+ positions
1898
+ };
1899
+ if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
1900
+ if (numberLine.style !== void 0) item.style = numberLine.style;
1901
+ return item;
1902
+ }
1668
1903
  function transformTable(table, idGenerator) {
1669
1904
  const rows = table.rows.map((row) => {
1670
1905
  const cells = row.cells.map((cell) => {
@@ -2146,6 +2381,8 @@ function validateInputFormats(exercise, userInputs) {
2146
2381
  const DragItemTypeSchema = z.enum([
2147
2382
  "drag-option",
2148
2383
  // Draggable answer options
2384
+ "placed-chip",
2385
+ // Chip already placed on a number-line axis (re-drag)
2149
2386
  "ruler-handle",
2150
2387
  // Future: ruler endpoints
2151
2388
  "shape",
@@ -5857,7 +6094,8 @@ const ExerciseDndProvider = ({
5857
6094
  }, [dropHandlers]);
5858
6095
  const handleDragStart = (event) => {
5859
6096
  const { active } = event;
5860
- const item = active.data.current;
6097
+ const raw = active.data.current;
6098
+ const item = raw?.type === "placed-chip" ? { id: String(active.id), type: "placed-chip", data: { label: raw.label } } : raw;
5861
6099
  setActiveItem(item);
5862
6100
  };
5863
6101
  const handleDragEnd = (event) => {
@@ -5908,6 +6146,10 @@ const DragOverlayContent = ({ item }) => {
5908
6146
  const data = item.data;
5909
6147
  return /* @__PURE__ */ jsx("div", { className: "drag-overlay-option", children: data.label });
5910
6148
  }
6149
+ case "placed-chip": {
6150
+ const label = item.data?.label ?? "";
6151
+ return /* @__PURE__ */ jsx("div", { className: "drag-overlay-option", children: label });
6152
+ }
5911
6153
  default:
5912
6154
  return null;
5913
6155
  }
@@ -6366,6 +6608,334 @@ const WidgetRenderer = ({
6366
6608
  }
6367
6609
  return null;
6368
6610
  };
6611
+ const cleanFloat = (n) => parseFloat(n.toFixed(10));
6612
+ const toPercent = (value, min, max) => {
6613
+ const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
6614
+ return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
6615
+ };
6616
+ const pixelToValue = (chipCenterX, axisLeft, axisWidth, min, max) => {
6617
+ const relX = (chipCenterX - axisLeft) / axisWidth;
6618
+ const fraction = (relX - SIDE_PAD / 100) / (1 - 2 * SIDE_PAD / 100);
6619
+ const clamped = Math.max(0, Math.min(1, fraction));
6620
+ return min + clamped * (max - min);
6621
+ };
6622
+ const getMajorTicks = (min, max, step) => {
6623
+ if (step <= 0 || max <= min) return [min, max];
6624
+ const count = Math.floor((max - min) / step);
6625
+ const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
6626
+ const lastTick = ticks[ticks.length - 1];
6627
+ if (Math.abs(lastTick - max) > 1e-9) ticks.push(max);
6628
+ return ticks;
6629
+ };
6630
+ const getMinorTickValues = (majorTicks, minorTicks) => {
6631
+ if (minorTicks <= 0) return [];
6632
+ const result = [];
6633
+ majorTicks.slice(0, -1).forEach((tick, idx) => {
6634
+ const next = majorTicks[idx + 1];
6635
+ const interval = next - tick;
6636
+ for (let j = 1; j <= minorTicks; j++) {
6637
+ result.push(cleanFloat(tick + j / (minorTicks + 1) * interval));
6638
+ }
6639
+ });
6640
+ return result;
6641
+ };
6642
+ const getSnapTicks = (majorTicks, minorTicks) => [
6643
+ ...majorTicks,
6644
+ ...getMinorTickValues(majorTicks, minorTicks)
6645
+ ];
6646
+ const snapToNearest = (raw, ticks) => {
6647
+ if (ticks.length === 0) return cleanFloat(raw);
6648
+ const snapped = ticks.reduce(
6649
+ (best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best
6650
+ );
6651
+ return cleanFloat(snapped);
6652
+ };
6653
+ const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
6654
+ const getPositionWidgetId = (content) => {
6655
+ for (const item of content) {
6656
+ if (isWidgetRef(item)) return item.widgetId;
6657
+ }
6658
+ return null;
6659
+ };
6660
+ const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
6661
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
6662
+ id: widgetId,
6663
+ data: { type: "placed-chip", widgetId, label }
6664
+ });
6665
+ return /* @__PURE__ */ jsx(
6666
+ "div",
6667
+ {
6668
+ className: "number-line-placed-chip",
6669
+ style: { left: `${leftPercent}%`, opacity: isDragging ? 0.3 : 1 },
6670
+ children: /* @__PURE__ */ jsx(
6671
+ "button",
6672
+ {
6673
+ ref: setNodeRef,
6674
+ className: "number-line-placed-chip-btn",
6675
+ title: "Drag to move · Click to remove",
6676
+ onClick: onRemove,
6677
+ ...listeners,
6678
+ ...attributes,
6679
+ children: label
6680
+ }
6681
+ )
6682
+ }
6683
+ );
6684
+ };
6685
+ const FreeDragAxis = ({
6686
+ axisDropId,
6687
+ min,
6688
+ max,
6689
+ minorTicks,
6690
+ majorTicks,
6691
+ showArrowLeft,
6692
+ showArrowRight,
6693
+ positions,
6694
+ values,
6695
+ onChange
6696
+ }) => {
6697
+ const axisRef = useRef(null);
6698
+ const snapTicks = useMemo(
6699
+ () => getSnapTicks(majorTicks, minorTicks),
6700
+ [majorTicks, minorTicks]
6701
+ );
6702
+ const [dropLabels, setDropLabels] = useState(/* @__PURE__ */ new Map());
6703
+ const [dropSources, setDropSources] = useState(/* @__PURE__ */ new Map());
6704
+ useEffect(() => {
6705
+ setDropLabels((prev) => {
6706
+ let changed = false;
6707
+ const next = new Map(prev);
6708
+ for (const [wid] of next) {
6709
+ if (!values[wid]) {
6710
+ next.delete(wid);
6711
+ changed = true;
6712
+ }
6713
+ }
6714
+ return changed ? next : prev;
6715
+ });
6716
+ setDropSources((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
+ }, [values]);
6728
+ const { setNodeRef, isOver } = useDroppable({ id: axisDropId });
6729
+ const mergedRef = useCallback((node) => {
6730
+ axisRef.current = node;
6731
+ setNodeRef(node);
6732
+ }, [setNodeRef]);
6733
+ const snapFromEvent = useCallback((translated) => {
6734
+ if (!axisRef.current) return null;
6735
+ const axisRect = axisRef.current.getBoundingClientRect();
6736
+ const chipCenterX = translated.left + translated.width / 2;
6737
+ const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
6738
+ return snapToNearest(raw, snapTicks);
6739
+ }, [min, max, snapTicks]);
6740
+ useDndMonitor({
6741
+ onDragEnd(event) {
6742
+ if (!event.over || event.over.id !== axisDropId) return;
6743
+ if (!axisRef.current) return;
6744
+ const translated = event.active.rect.current.translated;
6745
+ if (!translated) return;
6746
+ const placed = snapFromEvent(translated);
6747
+ if (placed === null) return;
6748
+ const data = event.active.data.current;
6749
+ if (data?.type === "placed-chip") {
6750
+ const widgetId = data.widgetId;
6751
+ const label = data.label;
6752
+ const originalSourceId = dropSources.get(widgetId) ?? widgetId;
6753
+ onChange(widgetId, String(placed), true, originalSourceId);
6754
+ setDropLabels((prev) => new Map(prev).set(widgetId, label));
6755
+ return;
6756
+ }
6757
+ const draggedItem = event.active.data.current;
6758
+ if (draggedItem?.type !== "drag-option") return;
6759
+ const targetPos = positions.find((pos) => {
6760
+ const wid = getPositionWidgetId(pos.content);
6761
+ return wid && !values[wid];
6762
+ });
6763
+ if (!targetPos) return;
6764
+ const targetWidgetId = getPositionWidgetId(targetPos.content);
6765
+ if (!targetWidgetId) return;
6766
+ const chipLabel = draggedItem.data.value;
6767
+ const sourceOptId = String(event.active.id);
6768
+ onChange(targetWidgetId, String(placed), true, sourceOptId);
6769
+ setDropLabels((prev) => new Map(prev).set(targetWidgetId, chipLabel));
6770
+ setDropSources((prev) => new Map(prev).set(targetWidgetId, sourceOptId));
6771
+ }
6772
+ });
6773
+ return /* @__PURE__ */ jsxs(
6774
+ "div",
6775
+ {
6776
+ ref: mergedRef,
6777
+ className: `number-line-axis-row nl-axis-drag-mode${isOver ? " nl-axis-drag-over" : ""}`,
6778
+ children: [
6779
+ /* @__PURE__ */ jsx("div", { className: "number-line-track" }),
6780
+ showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left" }),
6781
+ showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right" }),
6782
+ getMinorTickValues(majorTicks, minorTicks).map((v) => /* @__PURE__ */ jsx(
6783
+ "div",
6784
+ {
6785
+ className: "number-line-minor-tick",
6786
+ style: { left: `${toPercent(v, min, max)}%` }
6787
+ },
6788
+ `minor-${v}`
6789
+ )),
6790
+ majorTicks.map((tick) => /* @__PURE__ */ jsx(
6791
+ "div",
6792
+ {
6793
+ className: "number-line-major-tick",
6794
+ style: { left: `${toPercent(tick, min, max)}%` }
6795
+ },
6796
+ `major-${tick}`
6797
+ )),
6798
+ positions.map((pos) => {
6799
+ const wid = getPositionWidgetId(pos.content);
6800
+ const dropPos = wid ? parseFloat(values[wid] ?? "") : NaN;
6801
+ const label = wid ? dropLabels.get(wid) : void 0;
6802
+ if (!wid || isNaN(dropPos) || !label) return null;
6803
+ return /* @__PURE__ */ jsx(
6804
+ PlacedChip,
6805
+ {
6806
+ widgetId: wid,
6807
+ label,
6808
+ leftPercent: toPercent(dropPos, min, max),
6809
+ onRemove: () => {
6810
+ const sourceId = dropSources.get(wid);
6811
+ onChange(wid, "", false, sourceId);
6812
+ setDropLabels((prev) => {
6813
+ const m = new Map(prev);
6814
+ m.delete(wid);
6815
+ return m;
6816
+ });
6817
+ setDropSources((prev) => {
6818
+ const m = new Map(prev);
6819
+ m.delete(wid);
6820
+ return m;
6821
+ });
6822
+ }
6823
+ },
6824
+ wid
6825
+ );
6826
+ })
6827
+ ]
6828
+ }
6829
+ );
6830
+ };
6831
+ const NumberLineRenderer = ({
6832
+ item,
6833
+ widgets,
6834
+ values,
6835
+ droppedInputs,
6836
+ onChange,
6837
+ onInputFocus
6838
+ }) => {
6839
+ const { min, max, step, minorTicks = 0, style = "arrow-right", positions } = item;
6840
+ const majorTicks = useMemo(() => getMajorTicks(min, max, step), [min, max, step]);
6841
+ const minorValues = useMemo(() => getMinorTickValues(majorTicks, minorTicks), [majorTicks, minorTicks]);
6842
+ const showArrowRight = style === "arrow-right" || style === "arrow-both";
6843
+ const showArrowLeft = style === "arrow-left" || style === "arrow-both";
6844
+ const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
6845
+ const inputTickSet = useMemo(() => {
6846
+ if (isDragMode) return /* @__PURE__ */ new Set();
6847
+ return new Set(
6848
+ positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
6849
+ );
6850
+ }, [isDragMode, positions]);
6851
+ return /* @__PURE__ */ jsxs(
6852
+ "div",
6853
+ {
6854
+ className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
6855
+ style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
6856
+ role: "img",
6857
+ "aria-label": `Number line from ${min} to ${max}`,
6858
+ children: [
6859
+ !isDragMode && /* @__PURE__ */ jsx("div", { className: "number-line-content-row", children: positions.map((pos) => /* @__PURE__ */ jsx(
6860
+ "div",
6861
+ {
6862
+ className: "number-line-position-content",
6863
+ style: { left: `${toPercent(pos.value, min, max)}%` },
6864
+ children: pos.content.map((subItem) => /* @__PURE__ */ jsx(
6865
+ LayoutRenderer,
6866
+ {
6867
+ item: subItem,
6868
+ widgets,
6869
+ values,
6870
+ droppedInputs,
6871
+ onChange,
6872
+ onInputFocus
6873
+ },
6874
+ subItem.id
6875
+ ))
6876
+ },
6877
+ pos.id
6878
+ )) }),
6879
+ isDragMode ? /* @__PURE__ */ jsx(
6880
+ FreeDragAxis,
6881
+ {
6882
+ axisDropId: `${item.id}--nl-axis`,
6883
+ min,
6884
+ max,
6885
+ minorTicks,
6886
+ majorTicks,
6887
+ showArrowLeft,
6888
+ showArrowRight,
6889
+ positions,
6890
+ values,
6891
+ onChange
6892
+ }
6893
+ ) : /* @__PURE__ */ jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
6894
+ /* @__PURE__ */ jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
6895
+ showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
6896
+ showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
6897
+ minorValues.map((v) => /* @__PURE__ */ jsx(
6898
+ "div",
6899
+ {
6900
+ className: "number-line-minor-tick",
6901
+ "aria-hidden": "true",
6902
+ style: { left: `${toPercent(v, min, max)}%` }
6903
+ },
6904
+ `minor-${v}`
6905
+ )),
6906
+ majorTicks.map((tick) => /* @__PURE__ */ jsx(
6907
+ "div",
6908
+ {
6909
+ className: "number-line-major-tick",
6910
+ style: { left: `${toPercent(tick, min, max)}%` }
6911
+ },
6912
+ `major-${tick}`
6913
+ )),
6914
+ positions.map((pos) => /* @__PURE__ */ jsx(
6915
+ "div",
6916
+ {
6917
+ className: "number-line-position-dot",
6918
+ style: { left: `${toPercent(pos.value, min, max)}%` }
6919
+ },
6920
+ `dot-${pos.id}`
6921
+ ))
6922
+ ] }),
6923
+ /* @__PURE__ */ jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
6924
+ const hasInputAtTick = inputTickSet.has(tick);
6925
+ return /* @__PURE__ */ jsx(
6926
+ "span",
6927
+ {
6928
+ className: "number-line-tick-label",
6929
+ style: { left: `${toPercent(tick, min, max)}%`, visibility: hasInputAtTick ? "hidden" : "visible" },
6930
+ children: tick
6931
+ },
6932
+ `lbl-${tick}`
6933
+ );
6934
+ }) })
6935
+ ]
6936
+ }
6937
+ );
6938
+ };
6369
6939
  function isArabic(text) {
6370
6940
  return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
6371
6941
  }
@@ -6533,6 +7103,18 @@ const LayoutRenderer = ({
6533
7103
  },
6534
7104
  cell.id
6535
7105
  )) }, row.id)) }) }) });
7106
+ case JSON_TYPES.NUMBER_LINE:
7107
+ return /* @__PURE__ */ jsx(
7108
+ NumberLineRenderer,
7109
+ {
7110
+ item,
7111
+ widgets,
7112
+ values,
7113
+ droppedInputs,
7114
+ onChange,
7115
+ onInputFocus
7116
+ }
7117
+ );
6536
7118
  default:
6537
7119
  return null;
6538
7120
  }
@@ -6620,6 +7202,20 @@ const ExerciseRenderer = forwardRef(({
6620
7202
  });
6621
7203
  continue;
6622
7204
  }
7205
+ if (item.type === JSON_TYPES.NUMBER_LINE) {
7206
+ if (currentGroup.length > 0) {
7207
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
7208
+ const subGroups = groupLineItems(currentGroup);
7209
+ groups.push({ items: currentGroup, hasArabic, subGroups });
7210
+ currentGroup = [];
7211
+ }
7212
+ groups.push({
7213
+ items: [item],
7214
+ hasArabic: false,
7215
+ subGroups: []
7216
+ });
7217
+ continue;
7218
+ }
6623
7219
  if (item.type === JSON_TYPES.LINEBREAK) {
6624
7220
  if (currentGroup.length > 0) {
6625
7221
  const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
@@ -6871,7 +7467,7 @@ export {
6871
7467
  isToolNode,
6872
7468
  isValidExpression,
6873
7469
  isValidationNode,
6874
- isWidgetRef,
7470
+ isWidgetRef$1 as isWidgetRef,
6875
7471
  parse,
6876
7472
  parseSafe,
6877
7473
  renderExercise,