@mathscan/math-exercise-engine 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createToken, Lexer, CstParser } from "chevrotain";
2
2
  import { z } from "zod";
3
3
  import { create, all } from "mathjs";
4
- import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
5
5
  import React, { useLayoutEffect, useEffect, useRef, useMemo, useCallback, useState, createContext, memo, useReducer, useContext, forwardRef, cloneElement, useImperativeHandle } from "react";
6
6
  import { unstable_batchedUpdates, createPortal } from "react-dom";
7
7
  import { createRoot } from "react-dom/client";
@@ -38,6 +38,10 @@ const AST_TYPES = {
38
38
  NUMBER_LINE_BLOCK: "NumberLineBlock",
39
39
  /** A position marker on the number line */
40
40
  NUMBER_LINE_POSITION: "NumberLinePosition",
41
+ /** A dynamic subdivision counter embedded in a number line */
42
+ NUMBER_LINE_COUNTER: "NumberLineCounter",
43
+ /** A highlighted range on the number line axis */
44
+ NUMBER_LINE_HIGHLIGHT: "NumberLineHighlight",
41
45
  // ═══════════════════════════════════════════════════════════════════════════
42
46
  // Tools
43
47
  // ═══════════════════════════════════════════════════════════════════════════
@@ -94,6 +98,10 @@ const JSON_TYPES = {
94
98
  NUMBER_LINE: "number-line",
95
99
  /** Position marker within a number line */
96
100
  NUMBER_LINE_POSITION: "number-line-position",
101
+ /** Dynamic subdivision counter embedded in a number line */
102
+ NUMBER_LINE_COUNTER: "number-line-counter",
103
+ /** Highlighted range within a number line */
104
+ NUMBER_LINE_HIGHLIGHT: "number-line-highlight",
97
105
  // ═══════════════════════════════════════════════════════════════════════════
98
106
  // Widget Types
99
107
  // ═══════════════════════════════════════════════════════════════════════════
@@ -120,8 +128,12 @@ const INPUT_TYPES = {
120
128
  NUMERIC: "numeric"
121
129
  };
122
130
  const NUMBER_LINE_STYLES = ["arrow-right", "arrow-left", "arrow-both", "line"];
131
+ const INTERNAL_PRECISION = 9;
132
+ const DISPLAY_PRECISION = 2;
123
133
  const SIDE_PAD = 7;
124
134
  const CSS_VAR_SIDE_PAD = "--nl-side-pad";
135
+ const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
136
+ const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
125
137
  const Newline = createToken({
126
138
  name: "Newline",
127
139
  pattern: /\r?\n/,
@@ -215,6 +227,14 @@ const CELL = createToken({
215
227
  name: "CELL",
216
228
  pattern: /CELL/
217
229
  });
230
+ const COUNTER = createToken({
231
+ name: "COUNTER",
232
+ pattern: /COUNTER/
233
+ });
234
+ const HIGHLIGHT = createToken({
235
+ name: "HIGHLIGHT",
236
+ pattern: /HIGHLIGHT(?!_TEXT)/
237
+ });
218
238
  const NumberLiteral = createToken({
219
239
  name: "NumberLiteral",
220
240
  pattern: /-?\d+(\.\d+)?/
@@ -322,6 +342,8 @@ const allTokens = [
322
342
  TABLE,
323
343
  ROW,
324
344
  CELL,
345
+ COUNTER,
346
+ HIGHLIGHT,
325
347
  // Literals
326
348
  NumberLiteral,
327
349
  StringLiteral,
@@ -429,16 +451,14 @@ class MathExerciseParser extends CstParser {
429
451
  });
430
452
  this.exerciseDocument = this.RULE("exerciseDocument", () => {
431
453
  this.MANY(() => {
432
- this.OR([
433
- { ALT: () => this.CONSUME(Newline) },
434
- { ALT: () => this.CONSUME(Space) }
435
- ]);
454
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
436
455
  });
437
456
  this.OPTION(() => {
438
457
  this.SUBRULE(this.question);
439
458
  });
440
- this.MANY2(() => {
441
- this.SUBRULE(this.contentBlock);
459
+ this.MANY2({
460
+ GATE: this.BACKTRACK(this.contentBlock),
461
+ DEF: () => this.SUBRULE(this.contentBlock)
442
462
  });
443
463
  this.MANY3(() => {
444
464
  this.SUBRULE(this.tool);
@@ -447,10 +467,7 @@ class MathExerciseParser extends CstParser {
447
467
  this.SUBRULE(this.validation);
448
468
  });
449
469
  this.MANY4(() => {
450
- this.OR2([
451
- { ALT: () => this.CONSUME2(Newline) },
452
- { ALT: () => this.CONSUME2(Space) }
453
- ]);
470
+ this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
454
471
  });
455
472
  });
456
473
  this.question = this.RULE("question", () => {
@@ -459,10 +476,7 @@ class MathExerciseParser extends CstParser {
459
476
  this.CONSUME(StringLiteral, { LABEL: "value" });
460
477
  this.CONSUME(RBracket);
461
478
  this.MANY(() => {
462
- this.OR([
463
- { ALT: () => this.CONSUME(Newline) },
464
- { ALT: () => this.CONSUME(Space) }
465
- ]);
479
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
466
480
  });
467
481
  });
468
482
  this.contentBlock = this.RULE("contentBlock", () => {
@@ -484,77 +498,50 @@ class MathExerciseParser extends CstParser {
484
498
  });
485
499
  this.table = this.RULE("table", () => {
486
500
  this.MANY(() => {
487
- this.OR2([
488
- { ALT: () => this.CONSUME(Space) },
489
- { ALT: () => this.CONSUME(Newline) }
490
- ]);
501
+ this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
491
502
  });
492
503
  this.CONSUME(TABLE);
493
504
  this.CONSUME(LBracket);
494
505
  this.SUBRULE(this.parameterList);
495
506
  this.CONSUME(RBracket);
496
507
  this.MANY2(() => {
497
- this.OR3([
498
- { ALT: () => this.CONSUME2(Space) },
499
- { ALT: () => this.CONSUME2(Newline) }
500
- ]);
508
+ this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
501
509
  });
502
510
  this.CONSUME(LBrace);
503
511
  this.MANY3(() => {
504
- this.OR4([
505
- { ALT: () => this.CONSUME3(Space) },
506
- { ALT: () => this.CONSUME3(Newline) }
507
- ]);
512
+ this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
508
513
  });
509
514
  this.AT_LEAST_ONE(() => {
510
515
  this.SUBRULE(this.row);
511
516
  });
512
517
  this.CONSUME(RBrace);
513
518
  this.MANY4(() => {
514
- this.OR5([
515
- { ALT: () => this.CONSUME4(Newline) },
516
- { ALT: () => this.CONSUME4(Space) }
517
- ]);
519
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
518
520
  });
519
521
  });
520
522
  this.row = this.RULE("row", () => {
521
523
  this.MANY(() => {
522
- this.OR([
523
- { ALT: () => this.CONSUME(Space) },
524
- { ALT: () => this.CONSUME(Newline) }
525
- ]);
524
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
526
525
  });
527
526
  this.CONSUME(ROW);
528
527
  this.MANY2(() => {
529
- this.OR2([
530
- { ALT: () => this.CONSUME2(Space) },
531
- { ALT: () => this.CONSUME2(Newline) }
532
- ]);
528
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
533
529
  });
534
530
  this.CONSUME(LBrace);
535
531
  this.MANY3(() => {
536
- this.OR3([
537
- { ALT: () => this.CONSUME3(Space) },
538
- { ALT: () => this.CONSUME3(Newline) }
539
- ]);
532
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
540
533
  });
541
534
  this.AT_LEAST_ONE(() => {
542
535
  this.SUBRULE(this.cell);
543
536
  });
544
537
  this.CONSUME(RBrace);
545
538
  this.MANY4(() => {
546
- this.OR4([
547
- { ALT: () => this.CONSUME4(Space) },
548
- { ALT: () => this.CONSUME4(Newline) }
549
- ]);
539
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
550
540
  });
551
541
  });
552
542
  this.cell = this.RULE("cell", () => {
553
543
  this.MANY(() => {
554
- this.OR([
555
- { ALT: () => this.CONSUME(Space) },
556
- { ALT: () => this.CONSUME(Newline) }
557
- ]);
544
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
558
545
  });
559
546
  this.CONSUME(CELL);
560
547
  this.OPTION(() => {
@@ -563,10 +550,7 @@ class MathExerciseParser extends CstParser {
563
550
  this.CONSUME(RBracket);
564
551
  });
565
552
  this.MANY2(() => {
566
- this.OR2([
567
- { ALT: () => this.CONSUME2(Space) },
568
- { ALT: () => this.CONSUME2(Newline) }
569
- ]);
553
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
570
554
  });
571
555
  this.CONSUME(LBrace);
572
556
  this.MANY3(() => {
@@ -577,70 +561,47 @@ class MathExerciseParser extends CstParser {
577
561
  });
578
562
  this.CONSUME(RBrace);
579
563
  this.MANY4(() => {
580
- this.OR4([
581
- { ALT: () => this.CONSUME4(Space) },
582
- { ALT: () => this.CONSUME4(Newline) }
583
- ]);
564
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
584
565
  });
585
566
  });
586
567
  this.numberLine = this.RULE("numberLine", () => {
587
568
  this.MANY(() => {
588
- this.OR2([
589
- { ALT: () => this.CONSUME(Space) },
590
- { ALT: () => this.CONSUME(Newline) }
591
- ]);
569
+ this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
592
570
  });
593
571
  this.CONSUME(NUMBER_LINE);
594
572
  this.CONSUME(LBracket);
595
573
  this.SUBRULE(this.parameterList);
596
574
  this.CONSUME(RBracket);
597
575
  this.MANY2(() => {
598
- this.OR3([
599
- { ALT: () => this.CONSUME2(Space) },
600
- { ALT: () => this.CONSUME2(Newline) }
601
- ]);
576
+ this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
602
577
  });
603
578
  this.CONSUME(LBrace);
604
579
  this.MANY3(() => {
605
- this.OR4([
606
- { ALT: () => this.CONSUME3(Space) },
607
- { ALT: () => this.CONSUME3(Newline) }
608
- ]);
580
+ this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
609
581
  });
610
582
  this.MANY4(() => {
611
- this.SUBRULE(this.position);
583
+ this.OR6([
584
+ { ALT: () => this.SUBRULE(this.position) },
585
+ { ALT: () => this.SUBRULE(this.counter) },
586
+ { ALT: () => this.SUBRULE(this.highlight) }
587
+ ]);
612
588
  });
613
589
  this.CONSUME(RBrace);
614
590
  this.MANY5(() => {
615
- this.OR5([
616
- { ALT: () => this.CONSUME4(Newline) },
617
- { ALT: () => this.CONSUME4(Space) }
618
- ]);
591
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
619
592
  });
620
593
  });
621
594
  this.position = this.RULE("position", () => {
622
- this.MANY(() => {
623
- this.OR([
624
- { ALT: () => this.CONSUME(Space) },
625
- { ALT: () => this.CONSUME(Newline) }
626
- ]);
627
- });
628
595
  this.CONSUME(POSITION);
629
596
  this.CONSUME(LBracket);
630
597
  this.SUBRULE(this.parameterList);
631
598
  this.CONSUME(RBracket);
632
599
  this.MANY2(() => {
633
- this.OR2([
634
- { ALT: () => this.CONSUME2(Space) },
635
- { ALT: () => this.CONSUME2(Newline) }
636
- ]);
600
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
637
601
  });
638
602
  this.CONSUME(LBrace);
639
603
  this.MANY3(() => {
640
- this.OR3([
641
- { ALT: () => this.CONSUME3(Space) },
642
- { ALT: () => this.CONSUME3(Newline) }
643
- ]);
604
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
644
605
  });
645
606
  this.MANY4(() => {
646
607
  this.OR4([
@@ -650,10 +611,25 @@ class MathExerciseParser extends CstParser {
650
611
  });
651
612
  this.CONSUME(RBrace);
652
613
  this.MANY5(() => {
653
- this.OR5([
654
- { ALT: () => this.CONSUME5(Space) },
655
- { ALT: () => this.CONSUME5(Newline) }
656
- ]);
614
+ this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
615
+ });
616
+ });
617
+ this.counter = this.RULE("counter", () => {
618
+ this.CONSUME(COUNTER);
619
+ this.CONSUME(LBracket);
620
+ this.SUBRULE(this.parameterList);
621
+ this.CONSUME(RBracket);
622
+ this.MANY2(() => {
623
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
624
+ });
625
+ });
626
+ this.highlight = this.RULE("highlight", () => {
627
+ this.CONSUME(HIGHLIGHT);
628
+ this.CONSUME(LBracket);
629
+ this.SUBRULE(this.parameterList);
630
+ this.CONSUME(RBracket);
631
+ this.MANY2(() => {
632
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
657
633
  });
658
634
  });
659
635
  this.line = this.RULE("line", () => {
@@ -666,7 +642,7 @@ class MathExerciseParser extends CstParser {
666
642
  this.MANY2(() => {
667
643
  this.CONSUME2(Space);
668
644
  });
669
- this.AT_LEAST_ONE2(() => {
645
+ this.MANY3(() => {
670
646
  this.CONSUME(Newline);
671
647
  });
672
648
  });
@@ -750,28 +726,19 @@ class MathExerciseParser extends CstParser {
750
726
  this.SUBRULE(this.stringList);
751
727
  this.CONSUME(RBracket);
752
728
  this.MANY(() => {
753
- this.OR([
754
- { ALT: () => this.CONSUME(Newline) },
755
- { ALT: () => this.CONSUME(Space) }
756
- ]);
729
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
757
730
  });
758
731
  });
759
732
  this.radioButtons = this.RULE("radioButtons", () => {
760
733
  this.MANY(() => {
761
- this.OR([
762
- { ALT: () => this.CONSUME(Newline) },
763
- { ALT: () => this.CONSUME(Space) }
764
- ]);
734
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
765
735
  });
766
736
  this.CONSUME(RADIO_BUTTONS_OPTIONS);
767
737
  this.CONSUME(LBracket);
768
738
  this.SUBRULE(this.parameterList);
769
739
  this.CONSUME(RBracket);
770
740
  this.MANY2(() => {
771
- this.OR2([
772
- { ALT: () => this.CONSUME2(Newline) },
773
- { ALT: () => this.CONSUME2(Space) }
774
- ]);
741
+ this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
775
742
  });
776
743
  });
777
744
  this.numericKeypad = this.RULE("numericKeypad", () => {
@@ -782,10 +749,7 @@ class MathExerciseParser extends CstParser {
782
749
  });
783
750
  this.CONSUME(RBracket);
784
751
  this.MANY(() => {
785
- this.OR([
786
- { ALT: () => this.CONSUME(Newline) },
787
- { ALT: () => this.CONSUME(Space) }
788
- ]);
752
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
789
753
  });
790
754
  });
791
755
  this.validation = this.RULE("validation", () => {
@@ -1020,7 +984,7 @@ function isLineBlock(value) {
1020
984
  }
1021
985
  const NumberLinePositionSchema = z.object({
1022
986
  type: z.literal(AST_TYPES.NUMBER_LINE_POSITION),
1023
- value: z.number(),
987
+ value: z.number().finite({ message: "POSITION: value must be a finite number" }),
1024
988
  inlines: z.array(InlineNodeSchema).min(1)
1025
989
  });
1026
990
  function createNumberLinePosition(value, inlines) {
@@ -1040,10 +1004,50 @@ function createNumberLinePosition(value, inlines) {
1040
1004
  inlines
1041
1005
  });
1042
1006
  }
1007
+ const NumberLineCounterSchema = z.object({
1008
+ type: z.literal(AST_TYPES.NUMBER_LINE_COUNTER),
1009
+ min: z.number().int().min(1, { message: "COUNTER: min must be >= 1" }),
1010
+ max: z.number().int().positive({ message: "COUNTER: max must be > 0" }),
1011
+ default: z.number().int().nonnegative().optional(),
1012
+ name: z.string().optional()
1013
+ }).refine((d) => d.min <= d.max, {
1014
+ message: "COUNTER: min must be <= max"
1015
+ });
1016
+ function createNumberLineCounter(max, min, defaultValue2, name) {
1017
+ return NumberLineCounterSchema.parse({
1018
+ type: AST_TYPES.NUMBER_LINE_COUNTER,
1019
+ min: min ?? 1,
1020
+ max,
1021
+ ...defaultValue2 !== void 0 && { default: defaultValue2 },
1022
+ ...name !== void 0 && { name }
1023
+ });
1024
+ }
1025
+ const NumberLineHighlightSchema = z.object({
1026
+ type: z.literal(AST_TYPES.NUMBER_LINE_HIGHLIGHT),
1027
+ from: z.number().finite({ message: "HIGHLIGHT: from must be a finite number" }),
1028
+ to: z.number().finite({ message: "HIGHLIGHT: to must be a finite number" }),
1029
+ color: z.enum(NUMBER_LINE_HIGHLIGHT_COLORS, {
1030
+ errorMap: () => ({
1031
+ message: `HIGHLIGHT: color must be one of ${NUMBER_LINE_HIGHLIGHT_COLORS.join(", ")}`
1032
+ })
1033
+ }).optional(),
1034
+ label: z.string().optional()
1035
+ }).refine((d) => d.from < d.to, {
1036
+ message: "HIGHLIGHT: from must be less than to"
1037
+ });
1038
+ function createNumberLineHighlight(from, to, color, label) {
1039
+ return NumberLineHighlightSchema.parse({
1040
+ type: AST_TYPES.NUMBER_LINE_HIGHLIGHT,
1041
+ from,
1042
+ to,
1043
+ ...color !== void 0 && { color },
1044
+ ...label !== void 0 && { label }
1045
+ });
1046
+ }
1043
1047
  const NumberLineBlockSchema = z.object({
1044
1048
  type: z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
1045
- min: z.number(),
1046
- max: z.number(),
1049
+ min: z.number().finite({ message: "NUMBER_LINE: min must be a finite number" }),
1050
+ max: z.number().finite({ message: "NUMBER_LINE: max must be a finite number" }),
1047
1051
  step: z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
1048
1052
  minorTicks: z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
1049
1053
  style: z.enum(NUMBER_LINE_STYLES, {
@@ -1051,6 +1055,13 @@ const NumberLineBlockSchema = z.object({
1051
1055
  message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
1052
1056
  })
1053
1057
  }).optional(),
1058
+ labelVisibility: z.enum(NUMBER_LINE_LABELS, {
1059
+ errorMap: () => ({
1060
+ message: `NUMBER_LINE: labelVisibility must be one of ${NUMBER_LINE_LABELS.join(", ")}`
1061
+ })
1062
+ }).optional(),
1063
+ counter: NumberLineCounterSchema.optional(),
1064
+ highlights: z.array(NumberLineHighlightSchema).optional(),
1054
1065
  positions: z.array(NumberLinePositionSchema)
1055
1066
  }).refine((data) => data.min < data.max, {
1056
1067
  message: "NUMBER_LINE: min must be less than max"
@@ -1071,8 +1082,24 @@ const NumberLineBlockSchema = z.object({
1071
1082
  }
1072
1083
  seen.add(pos.value);
1073
1084
  }
1085
+ for (const h of data.highlights ?? []) {
1086
+ if (h.from < data.min || h.to > data.max) {
1087
+ ctx.addIssue({
1088
+ code: z.ZodIssueCode.custom,
1089
+ message: `NUMBER_LINE: HIGHLIGHT range [${h.from}, ${h.to}] must be within [${data.min}, ${data.max}]`
1090
+ });
1091
+ }
1092
+ }
1093
+ if (data.counter?.default !== void 0) {
1094
+ if (data.counter.default < data.counter.min || data.counter.default > data.counter.max) {
1095
+ ctx.addIssue({
1096
+ code: z.ZodIssueCode.custom,
1097
+ message: `NUMBER_LINE: COUNTER default (${data.counter.default}) must be within [${data.counter.min}, ${data.counter.max}]`
1098
+ });
1099
+ }
1100
+ }
1074
1101
  });
1075
- function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1102
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
1076
1103
  return NumberLineBlockSchema.parse({
1077
1104
  type: AST_TYPES.NUMBER_LINE_BLOCK,
1078
1105
  min,
@@ -1080,6 +1107,9 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1080
1107
  step,
1081
1108
  minorTicks,
1082
1109
  style,
1110
+ labelVisibility,
1111
+ counter,
1112
+ highlights,
1083
1113
  positions
1084
1114
  });
1085
1115
  }
@@ -1290,8 +1320,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1290
1320
  const step = params.step !== void 0 ? Number(params.step) : void 0;
1291
1321
  const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1292
1322
  const style = params.style;
1323
+ const labelVisibility = params.labelVisibility;
1293
1324
  const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1294
- return createNumberLineBlock(min, max, positions, step, minorTicks, style);
1325
+ const rawCounters = ctx.counter;
1326
+ if (rawCounters && rawCounters.length > 1) {
1327
+ throw new Error("NUMBER_LINE: only one COUNTER block is allowed");
1328
+ }
1329
+ const counter = rawCounters ? this.visit(rawCounters) : void 0;
1330
+ const highlights = ctx.highlight ? ctx.highlight.map((h) => this.visit(h)) : [];
1331
+ return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights);
1295
1332
  }
1296
1333
  position(ctx) {
1297
1334
  const params = this.visit(ctx.parameterList);
@@ -1302,6 +1339,28 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1302
1339
  const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
1303
1340
  return createNumberLinePosition(value, inlines);
1304
1341
  }
1342
+ counter(ctx) {
1343
+ const params = this.visit(ctx.parameterList);
1344
+ if (params.max === void 0) {
1345
+ throw new Error("COUNTER requires parameter: max");
1346
+ }
1347
+ const max = Number(params.max);
1348
+ const min = params.min !== void 0 ? Number(params.min) : void 0;
1349
+ const defaultValue2 = params.default !== void 0 ? Number(params.default) : void 0;
1350
+ const name = params.name;
1351
+ return createNumberLineCounter(max, min, defaultValue2, name);
1352
+ }
1353
+ highlight(ctx) {
1354
+ const params = this.visit(ctx.parameterList);
1355
+ if (params.from === void 0 || params.to === void 0) {
1356
+ throw new Error("HIGHLIGHT requires parameters: from and to");
1357
+ }
1358
+ const from = Number(params.from);
1359
+ const to = Number(params.to);
1360
+ const color = params.color;
1361
+ const label = params.label;
1362
+ return createNumberLineHighlight(from, to, color, label);
1363
+ }
1305
1364
  // ═══════════════════════════════════════════════════════════════════════════
1306
1365
  // Line Block
1307
1366
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1632,17 +1691,36 @@ const TableItemSchema = z.object({
1632
1691
  const NumberLinePositionItemSchema = z.object({
1633
1692
  id: z.string(),
1634
1693
  type: z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
1635
- value: z.number(),
1636
- content: z.array(z.lazy(() => LayoutItemSchema))
1694
+ value: z.number().finite(),
1695
+ content: z.array(LayoutItemSchema)
1696
+ });
1697
+ const NumberLineCounterItemSchema = z.object({
1698
+ id: z.string(),
1699
+ type: z.literal(JSON_TYPES.NUMBER_LINE_COUNTER),
1700
+ min: z.number().int().min(1),
1701
+ max: z.number().int().positive(),
1702
+ default: z.number().int().nonnegative().optional(),
1703
+ name: z.string().optional()
1704
+ });
1705
+ const NumberLineHighlightItemSchema = z.object({
1706
+ id: z.string(),
1707
+ type: z.literal(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
1708
+ from: z.number().finite(),
1709
+ to: z.number().finite(),
1710
+ color: z.enum(NUMBER_LINE_HIGHLIGHT_COLORS).optional(),
1711
+ label: z.string().optional()
1637
1712
  });
1638
1713
  const NumberLineItemSchema = z.object({
1639
1714
  id: z.string(),
1640
1715
  type: z.literal(JSON_TYPES.NUMBER_LINE),
1641
- min: z.number(),
1642
- max: z.number(),
1716
+ min: z.number().finite(),
1717
+ max: z.number().finite(),
1643
1718
  step: z.number().positive(),
1644
1719
  minorTicks: z.number().int().nonnegative().optional(),
1645
1720
  style: z.enum(NUMBER_LINE_STYLES).optional(),
1721
+ labelVisibility: z.enum(NUMBER_LINE_LABELS).optional(),
1722
+ counter: NumberLineCounterItemSchema.optional(),
1723
+ highlights: z.array(NumberLineHighlightItemSchema).optional(),
1646
1724
  positions: z.array(NumberLinePositionItemSchema)
1647
1725
  });
1648
1726
  const StringInputJSONSchema = z.object({
@@ -1716,7 +1794,7 @@ function isQuestionText(item) {
1716
1794
  function isTextItem$1(item) {
1717
1795
  return item.type === JSON_TYPES.TEXT;
1718
1796
  }
1719
- function isWidgetRef$1(item) {
1797
+ function isWidgetRef(item) {
1720
1798
  return item.type === JSON_TYPES.WIDGET_REF;
1721
1799
  }
1722
1800
  function isHighlightedText(item) {
@@ -1889,16 +1967,42 @@ function transformNumberLine(numberLine, idGenerator) {
1889
1967
  content
1890
1968
  };
1891
1969
  });
1970
+ let counterItem;
1971
+ if (numberLine.counter !== void 0) {
1972
+ const c = numberLine.counter;
1973
+ counterItem = {
1974
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_COUNTER),
1975
+ type: JSON_TYPES.NUMBER_LINE_COUNTER,
1976
+ min: c.min,
1977
+ max: c.max,
1978
+ ...c.default !== void 0 && { default: c.default },
1979
+ ...c.name !== void 0 && { name: c.name }
1980
+ };
1981
+ }
1982
+ let highlights;
1983
+ if (numberLine.highlights !== void 0 && numberLine.highlights.length > 0) {
1984
+ highlights = numberLine.highlights.map((h) => ({
1985
+ id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
1986
+ type: JSON_TYPES.NUMBER_LINE_HIGHLIGHT,
1987
+ from: h.from,
1988
+ to: h.to,
1989
+ ...h.color !== void 0 && { color: h.color },
1990
+ ...h.label !== void 0 && { label: h.label }
1991
+ }));
1992
+ }
1892
1993
  const item = {
1893
1994
  id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
1894
1995
  type: JSON_TYPES.NUMBER_LINE,
1895
1996
  min: numberLine.min,
1896
1997
  max: numberLine.max,
1897
1998
  step: numberLine.step,
1999
+ ...numberLine.minorTicks !== void 0 && { minorTicks: numberLine.minorTicks },
2000
+ ...numberLine.style !== void 0 && { style: numberLine.style },
2001
+ ...numberLine.labelVisibility !== void 0 && { labelVisibility: numberLine.labelVisibility },
2002
+ ...counterItem !== void 0 && { counter: counterItem },
2003
+ ...highlights !== void 0 && { highlights },
1898
2004
  positions
1899
2005
  };
1900
- if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
1901
- if (numberLine.style !== void 0) item.style = numberLine.style;
1902
2006
  return item;
1903
2007
  }
1904
2008
  function transformTable(table, idGenerator) {
@@ -2021,6 +2125,49 @@ function extractVariables$1(expression, validVariables) {
2021
2125
  }
2022
2126
  return uniqueVars;
2023
2127
  }
2128
+ function normalize(value) {
2129
+ if (typeof value === "number") {
2130
+ return String(Math.round(value * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
2131
+ }
2132
+ const trimmed = String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
2133
+ const asNum = Number(trimmed);
2134
+ if (trimmed !== "" && !isNaN(asNum)) {
2135
+ return String(Math.round(asNum * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
2136
+ }
2137
+ return trimmed;
2138
+ }
2139
+ const NL_DRAG_PREFIX = "\0nl-drag\0";
2140
+ const isNLDragEncoded = (v) => v.startsWith(NL_DRAG_PREFIX);
2141
+ const encodeNLDragValue = (label, position) => `${NL_DRAG_PREFIX}${label}@${String(position)}`;
2142
+ function isNLDragCorrect(encoded, expectedLabel, expectedPosition) {
2143
+ const payload = encoded.slice(NL_DRAG_PREFIX.length);
2144
+ const atIdx = payload.lastIndexOf("@");
2145
+ const label = normalize(payload.slice(0, atIdx));
2146
+ const position = normalize(payload.slice(atIdx + 1));
2147
+ return label === normalize(expectedLabel) && position === normalize(expectedPosition);
2148
+ }
2149
+ function collectNumberLines(items) {
2150
+ const result = [];
2151
+ for (const item of items) {
2152
+ if (item.type === JSON_TYPES.NUMBER_LINE) result.push(item);
2153
+ else if (item.type === JSON_TYPES.GROUP) result.push(...collectNumberLines(item.items));
2154
+ }
2155
+ return result;
2156
+ }
2157
+ function buildPositionMap(exercise) {
2158
+ const map = {};
2159
+ for (const nl of collectNumberLines(exercise.layout)) {
2160
+ for (const pos of nl.positions) {
2161
+ for (const c of pos.content) {
2162
+ if (isWidgetRef(c)) {
2163
+ map[c.widgetId] = pos.value;
2164
+ break;
2165
+ }
2166
+ }
2167
+ }
2168
+ }
2169
+ return map;
2170
+ }
2024
2171
  function validateSimple(exercise, userInputs) {
2025
2172
  const details = {};
2026
2173
  let allCorrect = true;
@@ -2034,10 +2181,11 @@ function validateSimple(exercise, userInputs) {
2034
2181
  score: 0
2035
2182
  };
2036
2183
  }
2184
+ const positionMap = buildPositionMap(exercise);
2037
2185
  for (const [widgetId, expected] of Object.entries(answers)) {
2038
2186
  totalCount++;
2039
2187
  const userValue = userInputs[widgetId];
2040
- const isCorrect = areValuesEqual(userValue, expected);
2188
+ const isCorrect = areValuesEqual(userValue, expected, positionMap[widgetId]);
2041
2189
  details[widgetId] = {
2042
2190
  widgetId,
2043
2191
  userValue,
@@ -2065,19 +2213,13 @@ function validateSimple(exercise, userInputs) {
2065
2213
  score: totalCount > 0 ? correctCount / totalCount : 0
2066
2214
  };
2067
2215
  }
2068
- function areValuesEqual(userValue, expected) {
2069
- if (userValue === void 0 || userValue === null) {
2070
- return false;
2071
- }
2072
- const normalizedUser = normalize(userValue);
2073
- const normalizedExpected = normalize(expected);
2074
- return normalizedUser === normalizedExpected;
2075
- }
2076
- function normalize(value) {
2077
- if (typeof value === "number") {
2078
- return String(Math.round(value * 1e10) / 1e10);
2216
+ function areValuesEqual(userValue, expected, expectedPosition) {
2217
+ if (userValue === void 0 || userValue === null) return false;
2218
+ if (typeof userValue === "string" && isNLDragEncoded(userValue)) {
2219
+ if (expectedPosition === void 0) return false;
2220
+ return isNLDragCorrect(userValue, expected, expectedPosition);
2079
2221
  }
2080
- return String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
2222
+ return normalize(userValue) === normalize(expected);
2081
2223
  }
2082
2224
  const math = create(all);
2083
2225
  const BLOCKED_FUNCTIONS = [
@@ -6619,7 +6761,10 @@ const WidgetRenderer = ({
6619
6761
  }
6620
6762
  return null;
6621
6763
  };
6622
- const cleanFloat = (n) => parseFloat(n.toFixed(10));
6764
+ const EPSILON = 10 ** -INTERNAL_PRECISION;
6765
+ const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
6766
+ const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
6767
+ const almostEqual = (a, b) => Math.abs(a - b) <= EPSILON;
6623
6768
  const toPercent = (value, min, max) => {
6624
6769
  const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
6625
6770
  return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
@@ -6635,19 +6780,24 @@ const getMajorTicks = (min, max, step) => {
6635
6780
  const count = Math.floor((max - min) / step);
6636
6781
  const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
6637
6782
  const lastTick = ticks[ticks.length - 1];
6638
- if (Math.abs(lastTick - max) > 1e-9) ticks.push(max);
6783
+ if (!almostEqual(lastTick, max)) {
6784
+ ticks.push(max);
6785
+ } else {
6786
+ ticks[ticks.length - 1] = max;
6787
+ }
6639
6788
  return ticks;
6640
6789
  };
6641
6790
  const getMinorTickValues = (majorTicks, minorTicks) => {
6642
- if (minorTicks <= 0) return [];
6791
+ if (minorTicks <= 0 || majorTicks.length < 2) return [];
6643
6792
  const result = [];
6644
- majorTicks.slice(0, -1).forEach((tick, idx) => {
6645
- const next = majorTicks[idx + 1];
6646
- const interval = next - tick;
6793
+ for (let i = 0; i < majorTicks.length - 1; i++) {
6794
+ const from = majorTicks[i];
6795
+ const to = majorTicks[i + 1];
6796
+ const interval = to - from;
6647
6797
  for (let j = 1; j <= minorTicks; j++) {
6648
- result.push(cleanFloat(tick + j / (minorTicks + 1) * interval));
6798
+ result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
6649
6799
  }
6650
- });
6800
+ }
6651
6801
  return result;
6652
6802
  };
6653
6803
  const getSnapTicks = (majorTicks, minorTicks) => [
@@ -6656,19 +6806,41 @@ const getSnapTicks = (majorTicks, minorTicks) => [
6656
6806
  ];
6657
6807
  const snapToNearest = (raw, ticks) => {
6658
6808
  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);
6809
+ return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
6663
6810
  };
6664
- const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
6665
6811
  const getPositionWidgetId = (content) => {
6666
6812
  for (const item of content) {
6667
6813
  if (isWidgetRef(item)) return item.widgetId;
6668
6814
  }
6669
6815
  return null;
6670
6816
  };
6671
- const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
6817
+ const toHighlightColor = (color) => `var(--nl-highlight-${color})`;
6818
+ const getBandGeometry = (h, min, max) => {
6819
+ const leftPct = toPercent(Math.min(h.from, h.to), min, max);
6820
+ const rightPct = toPercent(Math.max(h.from, h.to), min, max);
6821
+ return { leftPct, widthPct: rightPct - leftPct };
6822
+ };
6823
+ const HighlightBands = ({ highlights, min, max }) => {
6824
+ if (!highlights?.length) return null;
6825
+ return /* @__PURE__ */ jsx(Fragment, { children: highlights.map((h) => {
6826
+ const { leftPct, widthPct } = getBandGeometry(h, min, max);
6827
+ if (widthPct <= 0) return null;
6828
+ return /* @__PURE__ */ jsx(
6829
+ "div",
6830
+ {
6831
+ className: "number-line-highlight-band",
6832
+ "aria-hidden": "true",
6833
+ style: {
6834
+ left: `${leftPct}%`,
6835
+ width: `${widthPct}%`,
6836
+ ...h.color && { "--nl-highlight-color": toHighlightColor(h.color) }
6837
+ }
6838
+ },
6839
+ h.id
6840
+ );
6841
+ }) });
6842
+ };
6843
+ const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
6672
6844
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
6673
6845
  id: widgetId,
6674
6846
  data: { type: "placed-chip", widgetId, label }
@@ -6676,23 +6848,49 @@ const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
6676
6848
  return /* @__PURE__ */ jsx(
6677
6849
  "div",
6678
6850
  {
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
- )
6851
+ className: `number-line-placed-chip${isLanding ? " nl-chip-landing" : ""}${isDragging ? " nl-chip-dragging" : ""}`,
6852
+ style: { left: `${leftPercent}%` },
6853
+ children: /* @__PURE__ */ jsxs("div", { className: "number-line-placed-chip-inner", children: [
6854
+ /* @__PURE__ */ jsx(
6855
+ "button",
6856
+ {
6857
+ ref: setNodeRef,
6858
+ className: "number-line-placed-chip-btn",
6859
+ "aria-label": `${label}, drag to move`,
6860
+ ...listeners,
6861
+ ...attributes,
6862
+ children: label
6863
+ }
6864
+ ),
6865
+ /* @__PURE__ */ jsx(
6866
+ "button",
6867
+ {
6868
+ type: "button",
6869
+ className: "number-line-placed-chip-remove",
6870
+ "aria-label": "Remove",
6871
+ onClick: onRemove,
6872
+ children: "✕"
6873
+ }
6874
+ )
6875
+ ] })
6693
6876
  }
6694
6877
  );
6695
6878
  };
6879
+ function findNearestFreeWidgetSlot(positions, axisValue, values, placedChips, excludeWid) {
6880
+ let best;
6881
+ let bestDist = Infinity;
6882
+ for (const pos of positions) {
6883
+ const wid = getPositionWidgetId(pos.content);
6884
+ if (!wid) continue;
6885
+ if (wid !== excludeWid && (values[wid] || placedChips.has(wid))) continue;
6886
+ const dist = Math.abs(pos.value - axisValue);
6887
+ if (dist < bestDist) {
6888
+ bestDist = dist;
6889
+ best = pos;
6890
+ }
6891
+ }
6892
+ return best;
6893
+ }
6696
6894
  const FreeDragAxis = ({
6697
6895
  axisDropId,
6698
6896
  min,
@@ -6703,17 +6901,51 @@ const FreeDragAxis = ({
6703
6901
  showArrowRight,
6704
6902
  positions,
6705
6903
  values,
6706
- onChange
6904
+ onChange,
6905
+ highlights,
6906
+ snapKey
6707
6907
  }) => {
6708
6908
  const axisRef = useRef(null);
6709
6909
  const snapTicks = useMemo(
6710
6910
  () => getSnapTicks(majorTicks, minorTicks),
6711
6911
  [majorTicks, minorTicks]
6712
6912
  );
6713
- const [dropLabels, setDropLabels] = useState(/* @__PURE__ */ new Map());
6714
- const [dropSources, setDropSources] = useState(/* @__PURE__ */ new Map());
6913
+ const minorValues = useMemo(
6914
+ () => getMinorTickValues(majorTicks, minorTicks),
6915
+ [majorTicks, minorTicks]
6916
+ );
6917
+ const [placedChips, setPlacedChips] = useState(/* @__PURE__ */ new Map());
6918
+ const [hoverSnap, setHoverSnap] = useState(null);
6919
+ const [landingChips, setLandingChips] = useState(/* @__PURE__ */ new Set());
6920
+ const landingTimersRef = useRef(/* @__PURE__ */ new Map());
6921
+ useEffect(() => () => landingTimersRef.current.forEach(clearTimeout), []);
6922
+ const onChangeRef = useRef(onChange);
6923
+ const placedChipsRef = useRef(placedChips);
6924
+ useEffect(() => {
6925
+ onChangeRef.current = onChange;
6926
+ }, [onChange]);
6927
+ useEffect(() => {
6928
+ placedChipsRef.current = placedChips;
6929
+ }, [placedChips]);
6930
+ const isFirstRender = useRef(true);
6715
6931
  useEffect(() => {
6716
- setDropLabels((prev) => {
6932
+ if (isFirstRender.current) {
6933
+ isFirstRender.current = false;
6934
+ return;
6935
+ }
6936
+ for (const [widgetId, chip] of placedChipsRef.current) {
6937
+ onChangeRef.current(widgetId, "", false, chip.sourceId);
6938
+ }
6939
+ landingTimersRef.current.forEach(clearTimeout);
6940
+ landingTimersRef.current.clear();
6941
+ const empty = /* @__PURE__ */ new Map();
6942
+ placedChipsRef.current = empty;
6943
+ setPlacedChips(empty);
6944
+ setHoverSnap(null);
6945
+ setLandingChips(/* @__PURE__ */ new Set());
6946
+ }, [snapKey]);
6947
+ useEffect(() => {
6948
+ setPlacedChips((prev) => {
6717
6949
  let changed = false;
6718
6950
  const next = new Map(prev);
6719
6951
  for (const [wid] of next) {
@@ -6722,21 +6954,13 @@ const FreeDragAxis = ({
6722
6954
  changed = true;
6723
6955
  }
6724
6956
  }
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
- }
6957
+ if (changed) {
6958
+ placedChipsRef.current = next;
6735
6959
  }
6736
6960
  return changed ? next : prev;
6737
6961
  });
6738
6962
  }, [values]);
6739
- const { setNodeRef, isOver } = useDroppable({ id: axisDropId });
6963
+ const { setNodeRef } = useDroppable({ id: axisDropId });
6740
6964
  const mergedRef = useCallback((node) => {
6741
6965
  axisRef.current = node;
6742
6966
  setNodeRef(node);
@@ -6744,91 +6968,163 @@ const FreeDragAxis = ({
6744
6968
  const snapFromEvent = useCallback((translated) => {
6745
6969
  if (!axisRef.current) return null;
6746
6970
  const axisRect = axisRef.current.getBoundingClientRect();
6971
+ if (axisRect.width === 0) return null;
6747
6972
  const chipCenterX = translated.left + translated.width / 2;
6748
6973
  const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
6749
6974
  return snapToNearest(raw, snapTicks);
6750
6975
  }, [min, max, snapTicks]);
6976
+ const isTickOccupied = useCallback(
6977
+ (tick, excludeWid) => [...placedChipsRef.current.entries()].some(
6978
+ ([wid, c]) => wid !== excludeWid && almostEqual(c.position, tick)
6979
+ ),
6980
+ []
6981
+ // reads placedChipsRef (a ref — stable, no dep needed)
6982
+ );
6983
+ const findNearestFreeWidgetSlotCb = useCallback(
6984
+ (axisValue, excludeWid) => findNearestFreeWidgetSlot(positions, axisValue, values, placedChipsRef.current, excludeWid),
6985
+ [positions, values]
6986
+ );
6987
+ const triggerLanding = useCallback((widgetId) => {
6988
+ setLandingChips((prev) => new Set(prev).add(widgetId));
6989
+ clearTimeout(landingTimersRef.current.get(widgetId));
6990
+ landingTimersRef.current.set(widgetId, setTimeout(() => {
6991
+ setLandingChips((prev) => {
6992
+ const s = new Set(prev);
6993
+ s.delete(widgetId);
6994
+ return s;
6995
+ });
6996
+ landingTimersRef.current.delete(widgetId);
6997
+ }, 300));
6998
+ }, []);
6751
6999
  useDndMonitor({
7000
+ onDragMove(event) {
7001
+ if (!event.over || event.over.id !== axisDropId) {
7002
+ setHoverSnap(null);
7003
+ return;
7004
+ }
7005
+ const translated = event.active.rect.current.translated;
7006
+ if (!translated) return;
7007
+ setHoverSnap(snapFromEvent(translated));
7008
+ },
7009
+ onDragCancel() {
7010
+ setHoverSnap(null);
7011
+ },
6752
7012
  onDragEnd(event) {
7013
+ setHoverSnap(null);
6753
7014
  if (!event.over || event.over.id !== axisDropId) return;
6754
7015
  if (!axisRef.current) return;
6755
7016
  const translated = event.active.rect.current.translated;
6756
7017
  if (!translated) return;
6757
- const placed = snapFromEvent(translated);
6758
- if (placed === null) return;
7018
+ const rawPlaced = snapFromEvent(translated);
7019
+ if (rawPlaced === null) {
7020
+ if (process.env.NODE_ENV !== "production") {
7021
+ console.warn("[FreeDragAxis] snap calculation failed — axis may not be laid out yet");
7022
+ }
7023
+ return;
7024
+ }
7025
+ const placed = cleanFloat(rawPlaced);
6759
7026
  const data = event.active.data.current;
6760
- if (data?.type === "placed-chip") {
6761
- const widgetId = data.widgetId;
7027
+ if (!data) return;
7028
+ if (data.type === "placed-chip") {
7029
+ const oldWidgetId = data.widgetId;
6762
7030
  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));
7031
+ const sourceId2 = placedChipsRef.current.get(oldWidgetId)?.sourceId ?? oldWidgetId;
7032
+ if (isTickOccupied(placed, oldWidgetId)) return;
7033
+ const targetPos2 = findNearestFreeWidgetSlotCb(placed, oldWidgetId);
7034
+ if (!targetPos2) return;
7035
+ const newWidgetId = getPositionWidgetId(targetPos2.content);
7036
+ if (!newWidgetId) return;
7037
+ if (newWidgetId !== oldWidgetId) {
7038
+ onChangeRef.current(oldWidgetId, "", false, sourceId2);
7039
+ }
7040
+ onChangeRef.current(newWidgetId, encodeNLDragValue(label, placed), true, sourceId2);
7041
+ setPlacedChips((prev) => {
7042
+ const next = new Map(prev);
7043
+ if (newWidgetId !== oldWidgetId) next.delete(oldWidgetId);
7044
+ next.set(newWidgetId, { label, position: placed, sourceId: sourceId2 });
7045
+ placedChipsRef.current = next;
7046
+ return next;
7047
+ });
7048
+ triggerLanding(newWidgetId);
6766
7049
  return;
6767
7050
  }
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
- });
7051
+ if (data.type !== "drag-option") return;
7052
+ if (isTickOccupied(placed)) return;
7053
+ const targetPos = findNearestFreeWidgetSlotCb(placed);
6774
7054
  if (!targetPos) return;
6775
7055
  const targetWidgetId = getPositionWidgetId(targetPos.content);
6776
7056
  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));
7057
+ const chipLabel = data.data.value;
7058
+ const sourceId = String(event.active.id);
7059
+ onChangeRef.current(targetWidgetId, encodeNLDragValue(chipLabel, placed), true, sourceId);
7060
+ setPlacedChips((prev) => {
7061
+ const next = new Map(prev).set(targetWidgetId, { label: chipLabel, position: placed, sourceId });
7062
+ placedChipsRef.current = next;
7063
+ return next;
7064
+ });
7065
+ triggerLanding(targetWidgetId);
6782
7066
  }
6783
7067
  });
6784
7068
  return /* @__PURE__ */ jsxs(
6785
7069
  "div",
6786
7070
  {
6787
7071
  ref: mergedRef,
6788
- className: `number-line-axis-row nl-axis-drag-mode${isOver ? " nl-axis-drag-over" : ""}`,
7072
+ className: "number-line-axis-row nl-axis-drag-mode",
6789
7073
  children: [
6790
7074
  /* @__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(
7075
+ showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
7076
+ showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
7077
+ /* @__PURE__ */ jsx(HighlightBands, { highlights, min, max }),
7078
+ minorValues.map((v) => /* @__PURE__ */ jsx(
6794
7079
  "div",
6795
7080
  {
6796
- className: "number-line-minor-tick",
7081
+ className: `number-line-minor-tick${hoverSnap === v ? " nl-tick-hover" : ""}`,
6797
7082
  style: { left: `${toPercent(v, min, max)}%` }
6798
7083
  },
6799
- `minor-${v}`
7084
+ `minor-${cleanFloat(v)}`
6800
7085
  )),
6801
7086
  majorTicks.map((tick) => /* @__PURE__ */ jsx(
6802
7087
  "div",
6803
7088
  {
6804
- className: "number-line-major-tick",
7089
+ className: `number-line-major-tick${hoverSnap === tick ? " nl-tick-hover" : ""}`,
6805
7090
  style: { left: `${toPercent(tick, min, max)}%` }
6806
7091
  },
6807
- `major-${tick}`
7092
+ `major-${cleanFloat(tick)}`
7093
+ )),
7094
+ hoverSnap !== null && /* @__PURE__ */ jsx(
7095
+ "div",
7096
+ {
7097
+ className: "nl-snap-preview",
7098
+ style: { left: `${toPercent(hoverSnap, min, max)}%` },
7099
+ "aria-hidden": "true"
7100
+ }
7101
+ ),
7102
+ positions.filter((pos) => getPositionWidgetId(pos.content) === null).map((pos) => /* @__PURE__ */ jsx(
7103
+ "div",
7104
+ {
7105
+ className: "number-line-position-dot",
7106
+ style: { left: `${toPercent(pos.value, min, max)}%` }
7107
+ },
7108
+ `dot-${pos.id}`
6808
7109
  )),
6809
7110
  positions.map((pos) => {
6810
7111
  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;
7112
+ const chip = wid ? placedChips.get(wid) : void 0;
7113
+ if (!wid || !chip) return null;
6814
7114
  return /* @__PURE__ */ jsx(
6815
7115
  PlacedChip,
6816
7116
  {
6817
7117
  widgetId: wid,
6818
- label,
6819
- leftPercent: toPercent(dropPos, min, max),
7118
+ label: chip.label,
7119
+ leftPercent: toPercent(chip.position, min, max),
7120
+ isLanding: landingChips.has(wid),
6820
7121
  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;
7122
+ onChangeRef.current(wid, "", false, chip.sourceId);
7123
+ setPlacedChips((prev) => {
7124
+ const next = new Map(prev);
7125
+ next.delete(wid);
7126
+ placedChipsRef.current = next;
7127
+ return next;
6832
7128
  });
6833
7129
  }
6834
7130
  },
@@ -6839,6 +7135,132 @@ const FreeDragAxis = ({
6839
7135
  }
6840
7136
  );
6841
7137
  };
7138
+ const AxisTicks = ({
7139
+ min,
7140
+ max,
7141
+ majorTicks,
7142
+ minorValues,
7143
+ showArrowLeft,
7144
+ showArrowRight,
7145
+ highlights,
7146
+ positions
7147
+ }) => /* @__PURE__ */ jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
7148
+ /* @__PURE__ */ jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
7149
+ showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
7150
+ showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
7151
+ /* @__PURE__ */ jsx(HighlightBands, { highlights, min, max }),
7152
+ minorValues.map((v) => /* @__PURE__ */ jsx(
7153
+ "div",
7154
+ {
7155
+ className: "number-line-minor-tick",
7156
+ "aria-hidden": "true",
7157
+ style: { left: `${toPercent(v, min, max)}%` }
7158
+ },
7159
+ `minor-${v}`
7160
+ )),
7161
+ majorTicks.map((tick) => /* @__PURE__ */ jsx(
7162
+ "div",
7163
+ {
7164
+ className: "number-line-major-tick",
7165
+ "aria-hidden": "true",
7166
+ style: { left: `${toPercent(tick, min, max)}%` }
7167
+ },
7168
+ `major-${tick}`
7169
+ )),
7170
+ positions.map((pos) => /* @__PURE__ */ jsx(
7171
+ "div",
7172
+ {
7173
+ className: "number-line-position-dot",
7174
+ "aria-hidden": "true",
7175
+ style: { left: `${toPercent(pos.value, min, max)}%` }
7176
+ },
7177
+ `dot-${pos.id}`
7178
+ ))
7179
+ ] });
7180
+ const isLabelVisible = (tick, min, max, labelVisibility) => {
7181
+ switch (labelVisibility) {
7182
+ case "none":
7183
+ return false;
7184
+ case "min-only":
7185
+ return tick === roundTick(min);
7186
+ case "max-only":
7187
+ return tick === roundTick(max);
7188
+ case "extremes":
7189
+ return tick === roundTick(min) || tick === roundTick(max);
7190
+ default:
7191
+ return true;
7192
+ }
7193
+ };
7194
+ const AxisLabels = ({
7195
+ majorTicks,
7196
+ min,
7197
+ max,
7198
+ labelVisibility,
7199
+ inputTickSet
7200
+ }) => /* @__PURE__ */ jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
7201
+ const visible = !inputTickSet.has(tick) && isLabelVisible(tick, min, max, labelVisibility);
7202
+ return /* @__PURE__ */ jsx(
7203
+ "span",
7204
+ {
7205
+ className: "number-line-tick-label",
7206
+ style: {
7207
+ left: `${toPercent(tick, min, max)}%`,
7208
+ visibility: visible ? "visible" : "hidden"
7209
+ },
7210
+ children: tick
7211
+ },
7212
+ `lbl-${tick}`
7213
+ );
7214
+ }) });
7215
+ const HighlightLabelsRow = ({ highlights, min, max }) => {
7216
+ if (!highlights?.some((h) => h.label)) return null;
7217
+ return /* @__PURE__ */ jsx("div", { className: "number-line-highlight-labels-row", "aria-hidden": "true", children: highlights.map((h) => {
7218
+ if (!h.label) return null;
7219
+ const centerPct = (toPercent(h.from, min, max) + toPercent(h.to, min, max)) / 2;
7220
+ return /* @__PURE__ */ jsx(
7221
+ "span",
7222
+ {
7223
+ className: "number-line-highlight-label",
7224
+ style: { left: `${centerPct}%` },
7225
+ children: h.label
7226
+ },
7227
+ `hl-lbl-${h.id}`
7228
+ );
7229
+ }) });
7230
+ };
7231
+ const CounterControls = ({
7232
+ counter,
7233
+ count,
7234
+ onDecrement,
7235
+ onIncrement
7236
+ }) => /* @__PURE__ */ jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
7237
+ /* @__PURE__ */ jsxs("div", { className: "number-line-counter-controls", children: [
7238
+ /* @__PURE__ */ jsx(
7239
+ "button",
7240
+ {
7241
+ type: "button",
7242
+ className: "number-line-counter-btn",
7243
+ "aria-label": "Decrease",
7244
+ disabled: count <= counter.min,
7245
+ onClick: onDecrement,
7246
+ children: "−"
7247
+ }
7248
+ ),
7249
+ /* @__PURE__ */ jsx("span", { className: "number-line-counter-value", "data-testid": "counter-value", children: count }),
7250
+ /* @__PURE__ */ jsx(
7251
+ "button",
7252
+ {
7253
+ type: "button",
7254
+ className: "number-line-counter-btn",
7255
+ "aria-label": "Increase",
7256
+ disabled: count >= counter.max,
7257
+ onClick: onIncrement,
7258
+ children: "+"
7259
+ }
7260
+ )
7261
+ ] }),
7262
+ counter.name && /* @__PURE__ */ jsx("span", { className: "number-line-counter-label", children: counter.name })
7263
+ ] });
6842
7264
  const NumberLineRenderer = ({
6843
7265
  item,
6844
7266
  widgets,
@@ -6847,27 +7269,59 @@ const NumberLineRenderer = ({
6847
7269
  onChange,
6848
7270
  onInputFocus
6849
7271
  }) => {
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";
7272
+ const {
7273
+ min,
7274
+ max,
7275
+ step,
7276
+ minorTicks = 0,
7277
+ style: lineStyle = "arrow-right",
7278
+ labelVisibility,
7279
+ highlights,
7280
+ counter,
7281
+ positions
7282
+ } = item;
7283
+ const [counterCount, setCounterCount] = useState(
7284
+ counter !== void 0 ? counter.default ?? counter.min : 0
7285
+ );
7286
+ useEffect(() => {
7287
+ setCounterCount(counter !== void 0 ? counter.default ?? counter.min : 0);
7288
+ }, [counter]);
7289
+ const effectiveStep = useMemo(() => {
7290
+ if (counter !== void 0 && counterCount > 0) {
7291
+ return cleanFloat((max - min) / counterCount);
7292
+ }
7293
+ return step;
7294
+ }, [counter, counterCount, min, max, step]);
7295
+ const majorTicks = useMemo(
7296
+ () => getMajorTicks(min, max, effectiveStep).map(roundTick),
7297
+ [min, max, effectiveStep]
7298
+ );
7299
+ const minorValues = useMemo(
7300
+ () => getMinorTickValues(majorTicks, minorTicks),
7301
+ [majorTicks, minorTicks]
7302
+ );
7303
+ const showArrowRight = lineStyle === "arrow-right" || lineStyle === "arrow-both";
7304
+ const showArrowLeft = lineStyle === "arrow-left" || lineStyle === "arrow-both";
6855
7305
  const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
6856
7306
  const inputTickSet = useMemo(() => {
6857
7307
  if (isDragMode) return /* @__PURE__ */ new Set();
6858
7308
  return new Set(
6859
- positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
7309
+ positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
6860
7310
  );
6861
7311
  }, [isDragMode, positions]);
7312
+ const visiblePositions = useMemo(
7313
+ () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
7314
+ [isDragMode, positions]
7315
+ );
6862
7316
  return /* @__PURE__ */ jsxs(
6863
7317
  "div",
6864
7318
  {
6865
- className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
7319
+ className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
6866
7320
  style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
6867
- role: "img",
7321
+ role: "group",
6868
7322
  "aria-label": `Number line from ${min} to ${max}`,
6869
7323
  children: [
6870
- !isDragMode && /* @__PURE__ */ jsx("div", { className: "number-line-content-row", children: positions.map((pos) => /* @__PURE__ */ jsx(
7324
+ visiblePositions.length > 0 && /* @__PURE__ */ jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsx(
6871
7325
  "div",
6872
7326
  {
6873
7327
  className: "number-line-position-content",
@@ -6899,50 +7353,43 @@ const NumberLineRenderer = ({
6899
7353
  showArrowRight,
6900
7354
  positions,
6901
7355
  values,
6902
- onChange
7356
+ onChange,
7357
+ highlights,
7358
+ snapKey: counterCount
6903
7359
  }
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
- }) })
7360
+ ) : /* @__PURE__ */ jsx(
7361
+ AxisTicks,
7362
+ {
7363
+ min,
7364
+ max,
7365
+ majorTicks,
7366
+ minorValues,
7367
+ showArrowLeft,
7368
+ showArrowRight,
7369
+ highlights,
7370
+ positions
7371
+ }
7372
+ ),
7373
+ /* @__PURE__ */ jsx(
7374
+ AxisLabels,
7375
+ {
7376
+ majorTicks,
7377
+ min,
7378
+ max,
7379
+ labelVisibility,
7380
+ inputTickSet
7381
+ }
7382
+ ),
7383
+ /* @__PURE__ */ jsx(HighlightLabelsRow, { highlights, min, max }),
7384
+ counter !== void 0 && /* @__PURE__ */ jsx(
7385
+ CounterControls,
7386
+ {
7387
+ counter,
7388
+ count: counterCount,
7389
+ onDecrement: () => setCounterCount((c) => Math.max(counter.min, c - 1)),
7390
+ onIncrement: () => setCounterCount((c) => Math.min(counter.max, c + 1))
7391
+ }
7392
+ )
6946
7393
  ]
6947
7394
  }
6948
7395
  );
@@ -7170,8 +7617,7 @@ const ExerciseRenderer = forwardRef(({
7170
7617
  }
7171
7618
  }), [json, values, onSubmit]);
7172
7619
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
7173
- const newValues = { ...values, [widgetId]: value };
7174
- setValues(newValues);
7620
+ setValues((prev) => ({ ...prev, [widgetId]: value }));
7175
7621
  setDroppedSources((prev) => {
7176
7622
  const next = new Map(prev);
7177
7623
  if (fromDrop && value !== "" && sourceOptionId) {
@@ -7181,7 +7627,7 @@ const ExerciseRenderer = forwardRef(({
7181
7627
  }
7182
7628
  return next;
7183
7629
  });
7184
- onChange?.(newValues);
7630
+ onChange?.({ ...values, [widgetId]: value });
7185
7631
  };
7186
7632
  const handleSubmit = () => {
7187
7633
  onSubmit?.(values);
@@ -7405,6 +7851,10 @@ export {
7405
7851
  LexerError,
7406
7852
  LineBlockSchema,
7407
7853
  LinebreakSchema,
7854
+ NumberLineCounterItemSchema,
7855
+ NumberLineHighlightItemSchema,
7856
+ NumberLineItemSchema,
7857
+ NumberLinePositionItemSchema,
7408
7858
  NumericKeypadJSONSchema,
7409
7859
  NumericKeypadToolSchema,
7410
7860
  OperatorItemSchema,
@@ -7478,7 +7928,7 @@ export {
7478
7928
  isToolNode,
7479
7929
  isValidExpression,
7480
7930
  isValidationNode,
7481
- isWidgetRef$1 as isWidgetRef,
7931
+ isWidgetRef,
7482
7932
  parse,
7483
7933
  parseSafe,
7484
7934
  renderExercise,