@mathscan/math-exercise-engine 1.0.11 → 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,10 +451,7 @@ 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);
@@ -448,10 +467,7 @@ class MathExerciseParser extends CstParser {
448
467
  this.SUBRULE(this.validation);
449
468
  });
450
469
  this.MANY4(() => {
451
- this.OR2([
452
- { ALT: () => this.CONSUME2(Newline) },
453
- { ALT: () => this.CONSUME2(Space) }
454
- ]);
470
+ this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
455
471
  });
456
472
  });
457
473
  this.question = this.RULE("question", () => {
@@ -460,10 +476,7 @@ class MathExerciseParser extends CstParser {
460
476
  this.CONSUME(StringLiteral, { LABEL: "value" });
461
477
  this.CONSUME(RBracket);
462
478
  this.MANY(() => {
463
- this.OR([
464
- { ALT: () => this.CONSUME(Newline) },
465
- { ALT: () => this.CONSUME(Space) }
466
- ]);
479
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
467
480
  });
468
481
  });
469
482
  this.contentBlock = this.RULE("contentBlock", () => {
@@ -485,77 +498,50 @@ class MathExerciseParser extends CstParser {
485
498
  });
486
499
  this.table = this.RULE("table", () => {
487
500
  this.MANY(() => {
488
- this.OR2([
489
- { ALT: () => this.CONSUME(Space) },
490
- { ALT: () => this.CONSUME(Newline) }
491
- ]);
501
+ this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
492
502
  });
493
503
  this.CONSUME(TABLE);
494
504
  this.CONSUME(LBracket);
495
505
  this.SUBRULE(this.parameterList);
496
506
  this.CONSUME(RBracket);
497
507
  this.MANY2(() => {
498
- this.OR3([
499
- { ALT: () => this.CONSUME2(Space) },
500
- { ALT: () => this.CONSUME2(Newline) }
501
- ]);
508
+ this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
502
509
  });
503
510
  this.CONSUME(LBrace);
504
511
  this.MANY3(() => {
505
- this.OR4([
506
- { ALT: () => this.CONSUME3(Space) },
507
- { ALT: () => this.CONSUME3(Newline) }
508
- ]);
512
+ this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
509
513
  });
510
514
  this.AT_LEAST_ONE(() => {
511
515
  this.SUBRULE(this.row);
512
516
  });
513
517
  this.CONSUME(RBrace);
514
518
  this.MANY4(() => {
515
- this.OR5([
516
- { ALT: () => this.CONSUME4(Newline) },
517
- { ALT: () => this.CONSUME4(Space) }
518
- ]);
519
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
519
520
  });
520
521
  });
521
522
  this.row = this.RULE("row", () => {
522
523
  this.MANY(() => {
523
- this.OR([
524
- { ALT: () => this.CONSUME(Space) },
525
- { ALT: () => this.CONSUME(Newline) }
526
- ]);
524
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
527
525
  });
528
526
  this.CONSUME(ROW);
529
527
  this.MANY2(() => {
530
- this.OR2([
531
- { ALT: () => this.CONSUME2(Space) },
532
- { ALT: () => this.CONSUME2(Newline) }
533
- ]);
528
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
534
529
  });
535
530
  this.CONSUME(LBrace);
536
531
  this.MANY3(() => {
537
- this.OR3([
538
- { ALT: () => this.CONSUME3(Space) },
539
- { ALT: () => this.CONSUME3(Newline) }
540
- ]);
532
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
541
533
  });
542
534
  this.AT_LEAST_ONE(() => {
543
535
  this.SUBRULE(this.cell);
544
536
  });
545
537
  this.CONSUME(RBrace);
546
538
  this.MANY4(() => {
547
- this.OR4([
548
- { ALT: () => this.CONSUME4(Space) },
549
- { ALT: () => this.CONSUME4(Newline) }
550
- ]);
539
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
551
540
  });
552
541
  });
553
542
  this.cell = this.RULE("cell", () => {
554
543
  this.MANY(() => {
555
- this.OR([
556
- { ALT: () => this.CONSUME(Space) },
557
- { ALT: () => this.CONSUME(Newline) }
558
- ]);
544
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
559
545
  });
560
546
  this.CONSUME(CELL);
561
547
  this.OPTION(() => {
@@ -564,10 +550,7 @@ class MathExerciseParser extends CstParser {
564
550
  this.CONSUME(RBracket);
565
551
  });
566
552
  this.MANY2(() => {
567
- this.OR2([
568
- { ALT: () => this.CONSUME2(Space) },
569
- { ALT: () => this.CONSUME2(Newline) }
570
- ]);
553
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
571
554
  });
572
555
  this.CONSUME(LBrace);
573
556
  this.MANY3(() => {
@@ -578,70 +561,47 @@ class MathExerciseParser extends CstParser {
578
561
  });
579
562
  this.CONSUME(RBrace);
580
563
  this.MANY4(() => {
581
- this.OR4([
582
- { ALT: () => this.CONSUME4(Space) },
583
- { ALT: () => this.CONSUME4(Newline) }
584
- ]);
564
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
585
565
  });
586
566
  });
587
567
  this.numberLine = this.RULE("numberLine", () => {
588
568
  this.MANY(() => {
589
- this.OR2([
590
- { ALT: () => this.CONSUME(Space) },
591
- { ALT: () => this.CONSUME(Newline) }
592
- ]);
569
+ this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
593
570
  });
594
571
  this.CONSUME(NUMBER_LINE);
595
572
  this.CONSUME(LBracket);
596
573
  this.SUBRULE(this.parameterList);
597
574
  this.CONSUME(RBracket);
598
575
  this.MANY2(() => {
599
- this.OR3([
600
- { ALT: () => this.CONSUME2(Space) },
601
- { ALT: () => this.CONSUME2(Newline) }
602
- ]);
576
+ this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
603
577
  });
604
578
  this.CONSUME(LBrace);
605
579
  this.MANY3(() => {
606
- this.OR4([
607
- { ALT: () => this.CONSUME3(Space) },
608
- { ALT: () => this.CONSUME3(Newline) }
609
- ]);
580
+ this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
610
581
  });
611
582
  this.MANY4(() => {
612
- 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
+ ]);
613
588
  });
614
589
  this.CONSUME(RBrace);
615
590
  this.MANY5(() => {
616
- this.OR5([
617
- { ALT: () => this.CONSUME4(Newline) },
618
- { ALT: () => this.CONSUME4(Space) }
619
- ]);
591
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
620
592
  });
621
593
  });
622
594
  this.position = this.RULE("position", () => {
623
- this.MANY(() => {
624
- this.OR([
625
- { ALT: () => this.CONSUME(Space) },
626
- { ALT: () => this.CONSUME(Newline) }
627
- ]);
628
- });
629
595
  this.CONSUME(POSITION);
630
596
  this.CONSUME(LBracket);
631
597
  this.SUBRULE(this.parameterList);
632
598
  this.CONSUME(RBracket);
633
599
  this.MANY2(() => {
634
- this.OR2([
635
- { ALT: () => this.CONSUME2(Space) },
636
- { ALT: () => this.CONSUME2(Newline) }
637
- ]);
600
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
638
601
  });
639
602
  this.CONSUME(LBrace);
640
603
  this.MANY3(() => {
641
- this.OR3([
642
- { ALT: () => this.CONSUME3(Space) },
643
- { ALT: () => this.CONSUME3(Newline) }
644
- ]);
604
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
645
605
  });
646
606
  this.MANY4(() => {
647
607
  this.OR4([
@@ -651,10 +611,25 @@ class MathExerciseParser extends CstParser {
651
611
  });
652
612
  this.CONSUME(RBrace);
653
613
  this.MANY5(() => {
654
- this.OR5([
655
- { ALT: () => this.CONSUME5(Space) },
656
- { ALT: () => this.CONSUME5(Newline) }
657
- ]);
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) }]);
658
633
  });
659
634
  });
660
635
  this.line = this.RULE("line", () => {
@@ -751,28 +726,19 @@ class MathExerciseParser extends CstParser {
751
726
  this.SUBRULE(this.stringList);
752
727
  this.CONSUME(RBracket);
753
728
  this.MANY(() => {
754
- this.OR([
755
- { ALT: () => this.CONSUME(Newline) },
756
- { ALT: () => this.CONSUME(Space) }
757
- ]);
729
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
758
730
  });
759
731
  });
760
732
  this.radioButtons = this.RULE("radioButtons", () => {
761
733
  this.MANY(() => {
762
- this.OR([
763
- { ALT: () => this.CONSUME(Newline) },
764
- { ALT: () => this.CONSUME(Space) }
765
- ]);
734
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
766
735
  });
767
736
  this.CONSUME(RADIO_BUTTONS_OPTIONS);
768
737
  this.CONSUME(LBracket);
769
738
  this.SUBRULE(this.parameterList);
770
739
  this.CONSUME(RBracket);
771
740
  this.MANY2(() => {
772
- this.OR2([
773
- { ALT: () => this.CONSUME2(Newline) },
774
- { ALT: () => this.CONSUME2(Space) }
775
- ]);
741
+ this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
776
742
  });
777
743
  });
778
744
  this.numericKeypad = this.RULE("numericKeypad", () => {
@@ -783,10 +749,7 @@ class MathExerciseParser extends CstParser {
783
749
  });
784
750
  this.CONSUME(RBracket);
785
751
  this.MANY(() => {
786
- this.OR([
787
- { ALT: () => this.CONSUME(Newline) },
788
- { ALT: () => this.CONSUME(Space) }
789
- ]);
752
+ this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
790
753
  });
791
754
  });
792
755
  this.validation = this.RULE("validation", () => {
@@ -1021,7 +984,7 @@ function isLineBlock(value) {
1021
984
  }
1022
985
  const NumberLinePositionSchema = z.object({
1023
986
  type: z.literal(AST_TYPES.NUMBER_LINE_POSITION),
1024
- value: z.number(),
987
+ value: z.number().finite({ message: "POSITION: value must be a finite number" }),
1025
988
  inlines: z.array(InlineNodeSchema).min(1)
1026
989
  });
1027
990
  function createNumberLinePosition(value, inlines) {
@@ -1041,10 +1004,50 @@ function createNumberLinePosition(value, inlines) {
1041
1004
  inlines
1042
1005
  });
1043
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
+ }
1044
1047
  const NumberLineBlockSchema = z.object({
1045
1048
  type: z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
1046
- min: z.number(),
1047
- 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" }),
1048
1051
  step: z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
1049
1052
  minorTicks: z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
1050
1053
  style: z.enum(NUMBER_LINE_STYLES, {
@@ -1052,6 +1055,13 @@ const NumberLineBlockSchema = z.object({
1052
1055
  message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
1053
1056
  })
1054
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(),
1055
1065
  positions: z.array(NumberLinePositionSchema)
1056
1066
  }).refine((data) => data.min < data.max, {
1057
1067
  message: "NUMBER_LINE: min must be less than max"
@@ -1072,8 +1082,24 @@ const NumberLineBlockSchema = z.object({
1072
1082
  }
1073
1083
  seen.add(pos.value);
1074
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
+ }
1075
1101
  });
1076
- function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1102
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
1077
1103
  return NumberLineBlockSchema.parse({
1078
1104
  type: AST_TYPES.NUMBER_LINE_BLOCK,
1079
1105
  min,
@@ -1081,6 +1107,9 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
1081
1107
  step,
1082
1108
  minorTicks,
1083
1109
  style,
1110
+ labelVisibility,
1111
+ counter,
1112
+ highlights,
1084
1113
  positions
1085
1114
  });
1086
1115
  }
@@ -1291,8 +1320,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1291
1320
  const step = params.step !== void 0 ? Number(params.step) : void 0;
1292
1321
  const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1293
1322
  const style = params.style;
1323
+ const labelVisibility = params.labelVisibility;
1294
1324
  const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1295
- 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);
1296
1332
  }
1297
1333
  position(ctx) {
1298
1334
  const params = this.visit(ctx.parameterList);
@@ -1303,6 +1339,28 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1303
1339
  const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
1304
1340
  return createNumberLinePosition(value, inlines);
1305
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
+ }
1306
1364
  // ═══════════════════════════════════════════════════════════════════════════
1307
1365
  // Line Block
1308
1366
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1633,17 +1691,36 @@ const TableItemSchema = z.object({
1633
1691
  const NumberLinePositionItemSchema = z.object({
1634
1692
  id: z.string(),
1635
1693
  type: z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
1636
- value: z.number(),
1637
- 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()
1638
1712
  });
1639
1713
  const NumberLineItemSchema = z.object({
1640
1714
  id: z.string(),
1641
1715
  type: z.literal(JSON_TYPES.NUMBER_LINE),
1642
- min: z.number(),
1643
- max: z.number(),
1716
+ min: z.number().finite(),
1717
+ max: z.number().finite(),
1644
1718
  step: z.number().positive(),
1645
1719
  minorTicks: z.number().int().nonnegative().optional(),
1646
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(),
1647
1724
  positions: z.array(NumberLinePositionItemSchema)
1648
1725
  });
1649
1726
  const StringInputJSONSchema = z.object({
@@ -1717,7 +1794,7 @@ function isQuestionText(item) {
1717
1794
  function isTextItem$1(item) {
1718
1795
  return item.type === JSON_TYPES.TEXT;
1719
1796
  }
1720
- function isWidgetRef$1(item) {
1797
+ function isWidgetRef(item) {
1721
1798
  return item.type === JSON_TYPES.WIDGET_REF;
1722
1799
  }
1723
1800
  function isHighlightedText(item) {
@@ -1890,16 +1967,42 @@ function transformNumberLine(numberLine, idGenerator) {
1890
1967
  content
1891
1968
  };
1892
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
+ }
1893
1993
  const item = {
1894
1994
  id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
1895
1995
  type: JSON_TYPES.NUMBER_LINE,
1896
1996
  min: numberLine.min,
1897
1997
  max: numberLine.max,
1898
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 },
1899
2004
  positions
1900
2005
  };
1901
- if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
1902
- if (numberLine.style !== void 0) item.style = numberLine.style;
1903
2006
  return item;
1904
2007
  }
1905
2008
  function transformTable(table, idGenerator) {
@@ -2022,6 +2125,49 @@ function extractVariables$1(expression, validVariables) {
2022
2125
  }
2023
2126
  return uniqueVars;
2024
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
+ }
2025
2171
  function validateSimple(exercise, userInputs) {
2026
2172
  const details = {};
2027
2173
  let allCorrect = true;
@@ -2035,10 +2181,11 @@ function validateSimple(exercise, userInputs) {
2035
2181
  score: 0
2036
2182
  };
2037
2183
  }
2184
+ const positionMap = buildPositionMap(exercise);
2038
2185
  for (const [widgetId, expected] of Object.entries(answers)) {
2039
2186
  totalCount++;
2040
2187
  const userValue = userInputs[widgetId];
2041
- const isCorrect = areValuesEqual(userValue, expected);
2188
+ const isCorrect = areValuesEqual(userValue, expected, positionMap[widgetId]);
2042
2189
  details[widgetId] = {
2043
2190
  widgetId,
2044
2191
  userValue,
@@ -2066,19 +2213,13 @@ function validateSimple(exercise, userInputs) {
2066
2213
  score: totalCount > 0 ? correctCount / totalCount : 0
2067
2214
  };
2068
2215
  }
2069
- function areValuesEqual(userValue, expected) {
2070
- if (userValue === void 0 || userValue === null) {
2071
- return false;
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);
2072
2221
  }
2073
- const normalizedUser = normalize(userValue);
2074
- const normalizedExpected = normalize(expected);
2075
- return normalizedUser === normalizedExpected;
2076
- }
2077
- function normalize(value) {
2078
- if (typeof value === "number") {
2079
- return String(Math.round(value * 1e10) / 1e10);
2080
- }
2081
- return String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
2222
+ return normalize(userValue) === normalize(expected);
2082
2223
  }
2083
2224
  const math = create(all);
2084
2225
  const BLOCKED_FUNCTIONS = [
@@ -6620,7 +6761,10 @@ const WidgetRenderer = ({
6620
6761
  }
6621
6762
  return null;
6622
6763
  };
6623
- 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;
6624
6768
  const toPercent = (value, min, max) => {
6625
6769
  const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
6626
6770
  return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
@@ -6636,19 +6780,24 @@ const getMajorTicks = (min, max, step) => {
6636
6780
  const count = Math.floor((max - min) / step);
6637
6781
  const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
6638
6782
  const lastTick = ticks[ticks.length - 1];
6639
- 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
+ }
6640
6788
  return ticks;
6641
6789
  };
6642
6790
  const getMinorTickValues = (majorTicks, minorTicks) => {
6643
- if (minorTicks <= 0) return [];
6791
+ if (minorTicks <= 0 || majorTicks.length < 2) return [];
6644
6792
  const result = [];
6645
- majorTicks.slice(0, -1).forEach((tick, idx) => {
6646
- const next = majorTicks[idx + 1];
6647
- 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;
6648
6797
  for (let j = 1; j <= minorTicks; j++) {
6649
- result.push(cleanFloat(tick + j / (minorTicks + 1) * interval));
6798
+ result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
6650
6799
  }
6651
- });
6800
+ }
6652
6801
  return result;
6653
6802
  };
6654
6803
  const getSnapTicks = (majorTicks, minorTicks) => [
@@ -6657,19 +6806,41 @@ const getSnapTicks = (majorTicks, minorTicks) => [
6657
6806
  ];
6658
6807
  const snapToNearest = (raw, ticks) => {
6659
6808
  if (ticks.length === 0) return cleanFloat(raw);
6660
- const snapped = ticks.reduce(
6661
- (best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best
6662
- );
6663
- return cleanFloat(snapped);
6809
+ return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
6664
6810
  };
6665
- const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
6666
6811
  const getPositionWidgetId = (content) => {
6667
6812
  for (const item of content) {
6668
6813
  if (isWidgetRef(item)) return item.widgetId;
6669
6814
  }
6670
6815
  return null;
6671
6816
  };
6672
- 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 }) => {
6673
6844
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
6674
6845
  id: widgetId,
6675
6846
  data: { type: "placed-chip", widgetId, label }
@@ -6677,23 +6848,49 @@ const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
6677
6848
  return /* @__PURE__ */ jsx(
6678
6849
  "div",
6679
6850
  {
6680
- className: "number-line-placed-chip",
6681
- style: { left: `${leftPercent}%`, opacity: isDragging ? 0.3 : 1 },
6682
- children: /* @__PURE__ */ jsx(
6683
- "button",
6684
- {
6685
- ref: setNodeRef,
6686
- className: "number-line-placed-chip-btn",
6687
- title: "Drag to move · Click to remove",
6688
- onClick: onRemove,
6689
- ...listeners,
6690
- ...attributes,
6691
- children: label
6692
- }
6693
- )
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
+ ] })
6694
6876
  }
6695
6877
  );
6696
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
+ }
6697
6894
  const FreeDragAxis = ({
6698
6895
  axisDropId,
6699
6896
  min,
@@ -6704,17 +6901,51 @@ const FreeDragAxis = ({
6704
6901
  showArrowRight,
6705
6902
  positions,
6706
6903
  values,
6707
- onChange
6904
+ onChange,
6905
+ highlights,
6906
+ snapKey
6708
6907
  }) => {
6709
6908
  const axisRef = useRef(null);
6710
6909
  const snapTicks = useMemo(
6711
6910
  () => getSnapTicks(majorTicks, minorTicks),
6712
6911
  [majorTicks, minorTicks]
6713
6912
  );
6714
- const [dropLabels, setDropLabels] = useState(/* @__PURE__ */ new Map());
6715
- 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]);
6716
6927
  useEffect(() => {
6717
- setDropLabels((prev) => {
6928
+ placedChipsRef.current = placedChips;
6929
+ }, [placedChips]);
6930
+ const isFirstRender = useRef(true);
6931
+ useEffect(() => {
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) => {
6718
6949
  let changed = false;
6719
6950
  const next = new Map(prev);
6720
6951
  for (const [wid] of next) {
@@ -6723,21 +6954,13 @@ const FreeDragAxis = ({
6723
6954
  changed = true;
6724
6955
  }
6725
6956
  }
6726
- return changed ? next : prev;
6727
- });
6728
- setDropSources((prev) => {
6729
- let changed = false;
6730
- const next = new Map(prev);
6731
- for (const [wid] of next) {
6732
- if (!values[wid]) {
6733
- next.delete(wid);
6734
- changed = true;
6735
- }
6957
+ if (changed) {
6958
+ placedChipsRef.current = next;
6736
6959
  }
6737
6960
  return changed ? next : prev;
6738
6961
  });
6739
6962
  }, [values]);
6740
- const { setNodeRef, isOver } = useDroppable({ id: axisDropId });
6963
+ const { setNodeRef } = useDroppable({ id: axisDropId });
6741
6964
  const mergedRef = useCallback((node) => {
6742
6965
  axisRef.current = node;
6743
6966
  setNodeRef(node);
@@ -6745,91 +6968,163 @@ const FreeDragAxis = ({
6745
6968
  const snapFromEvent = useCallback((translated) => {
6746
6969
  if (!axisRef.current) return null;
6747
6970
  const axisRect = axisRef.current.getBoundingClientRect();
6971
+ if (axisRect.width === 0) return null;
6748
6972
  const chipCenterX = translated.left + translated.width / 2;
6749
6973
  const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
6750
6974
  return snapToNearest(raw, snapTicks);
6751
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
+ }, []);
6752
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
+ },
6753
7012
  onDragEnd(event) {
7013
+ setHoverSnap(null);
6754
7014
  if (!event.over || event.over.id !== axisDropId) return;
6755
7015
  if (!axisRef.current) return;
6756
7016
  const translated = event.active.rect.current.translated;
6757
7017
  if (!translated) return;
6758
- const placed = snapFromEvent(translated);
6759
- 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);
6760
7026
  const data = event.active.data.current;
6761
- if (data?.type === "placed-chip") {
6762
- const widgetId = data.widgetId;
7027
+ if (!data) return;
7028
+ if (data.type === "placed-chip") {
7029
+ const oldWidgetId = data.widgetId;
6763
7030
  const label = data.label;
6764
- const originalSourceId = dropSources.get(widgetId) ?? widgetId;
6765
- onChange(widgetId, String(placed), true, originalSourceId);
6766
- 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);
6767
7049
  return;
6768
7050
  }
6769
- const draggedItem = event.active.data.current;
6770
- if (draggedItem?.type !== "drag-option") return;
6771
- const targetPos = positions.find((pos) => {
6772
- const wid = getPositionWidgetId(pos.content);
6773
- return wid && !values[wid];
6774
- });
7051
+ if (data.type !== "drag-option") return;
7052
+ if (isTickOccupied(placed)) return;
7053
+ const targetPos = findNearestFreeWidgetSlotCb(placed);
6775
7054
  if (!targetPos) return;
6776
7055
  const targetWidgetId = getPositionWidgetId(targetPos.content);
6777
7056
  if (!targetWidgetId) return;
6778
- const chipLabel = draggedItem.data.value;
6779
- const sourceOptId = String(event.active.id);
6780
- onChange(targetWidgetId, String(placed), true, sourceOptId);
6781
- setDropLabels((prev) => new Map(prev).set(targetWidgetId, chipLabel));
6782
- 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);
6783
7066
  }
6784
7067
  });
6785
7068
  return /* @__PURE__ */ jsxs(
6786
7069
  "div",
6787
7070
  {
6788
7071
  ref: mergedRef,
6789
- className: `number-line-axis-row nl-axis-drag-mode${isOver ? " nl-axis-drag-over" : ""}`,
7072
+ className: "number-line-axis-row nl-axis-drag-mode",
6790
7073
  children: [
6791
7074
  /* @__PURE__ */ jsx("div", { className: "number-line-track" }),
6792
- showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left" }),
6793
- showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right" }),
6794
- 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(
6795
7079
  "div",
6796
7080
  {
6797
- className: "number-line-minor-tick",
7081
+ className: `number-line-minor-tick${hoverSnap === v ? " nl-tick-hover" : ""}`,
6798
7082
  style: { left: `${toPercent(v, min, max)}%` }
6799
7083
  },
6800
- `minor-${v}`
7084
+ `minor-${cleanFloat(v)}`
6801
7085
  )),
6802
7086
  majorTicks.map((tick) => /* @__PURE__ */ jsx(
6803
7087
  "div",
6804
7088
  {
6805
- className: "number-line-major-tick",
7089
+ className: `number-line-major-tick${hoverSnap === tick ? " nl-tick-hover" : ""}`,
6806
7090
  style: { left: `${toPercent(tick, min, max)}%` }
6807
7091
  },
6808
- `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}`
6809
7109
  )),
6810
7110
  positions.map((pos) => {
6811
7111
  const wid = getPositionWidgetId(pos.content);
6812
- const dropPos = wid ? parseFloat(values[wid] ?? "") : NaN;
6813
- const label = wid ? dropLabels.get(wid) : void 0;
6814
- if (!wid || isNaN(dropPos) || !label) return null;
7112
+ const chip = wid ? placedChips.get(wid) : void 0;
7113
+ if (!wid || !chip) return null;
6815
7114
  return /* @__PURE__ */ jsx(
6816
7115
  PlacedChip,
6817
7116
  {
6818
7117
  widgetId: wid,
6819
- label,
6820
- leftPercent: toPercent(dropPos, min, max),
7118
+ label: chip.label,
7119
+ leftPercent: toPercent(chip.position, min, max),
7120
+ isLanding: landingChips.has(wid),
6821
7121
  onRemove: () => {
6822
- const sourceId = dropSources.get(wid);
6823
- onChange(wid, "", false, sourceId);
6824
- setDropLabels((prev) => {
6825
- const m = new Map(prev);
6826
- m.delete(wid);
6827
- return m;
6828
- });
6829
- setDropSources((prev) => {
6830
- const m = new Map(prev);
6831
- m.delete(wid);
6832
- 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;
6833
7128
  });
6834
7129
  }
6835
7130
  },
@@ -6840,6 +7135,132 @@ const FreeDragAxis = ({
6840
7135
  }
6841
7136
  );
6842
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
+ ] });
6843
7264
  const NumberLineRenderer = ({
6844
7265
  item,
6845
7266
  widgets,
@@ -6848,27 +7269,59 @@ const NumberLineRenderer = ({
6848
7269
  onChange,
6849
7270
  onInputFocus
6850
7271
  }) => {
6851
- const { min, max, step, minorTicks = 0, style = "arrow-right", positions } = item;
6852
- const majorTicks = useMemo(() => getMajorTicks(min, max, step), [min, max, step]);
6853
- const minorValues = useMemo(() => getMinorTickValues(majorTicks, minorTicks), [majorTicks, minorTicks]);
6854
- const showArrowRight = style === "arrow-right" || style === "arrow-both";
6855
- 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";
6856
7305
  const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
6857
7306
  const inputTickSet = useMemo(() => {
6858
7307
  if (isDragMode) return /* @__PURE__ */ new Set();
6859
7308
  return new Set(
6860
- positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
7309
+ positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
6861
7310
  );
6862
7311
  }, [isDragMode, positions]);
7312
+ const visiblePositions = useMemo(
7313
+ () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
7314
+ [isDragMode, positions]
7315
+ );
6863
7316
  return /* @__PURE__ */ jsxs(
6864
7317
  "div",
6865
7318
  {
6866
- className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
7319
+ className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
6867
7320
  style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
6868
- role: "img",
7321
+ role: "group",
6869
7322
  "aria-label": `Number line from ${min} to ${max}`,
6870
7323
  children: [
6871
- !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(
6872
7325
  "div",
6873
7326
  {
6874
7327
  className: "number-line-position-content",
@@ -6900,50 +7353,43 @@ const NumberLineRenderer = ({
6900
7353
  showArrowRight,
6901
7354
  positions,
6902
7355
  values,
6903
- onChange
7356
+ onChange,
7357
+ highlights,
7358
+ snapKey: counterCount
6904
7359
  }
6905
- ) : /* @__PURE__ */ jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
6906
- /* @__PURE__ */ jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
6907
- showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
6908
- showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
6909
- minorValues.map((v) => /* @__PURE__ */ jsx(
6910
- "div",
6911
- {
6912
- className: "number-line-minor-tick",
6913
- "aria-hidden": "true",
6914
- style: { left: `${toPercent(v, min, max)}%` }
6915
- },
6916
- `minor-${v}`
6917
- )),
6918
- majorTicks.map((tick) => /* @__PURE__ */ jsx(
6919
- "div",
6920
- {
6921
- className: "number-line-major-tick",
6922
- style: { left: `${toPercent(tick, min, max)}%` }
6923
- },
6924
- `major-${tick}`
6925
- )),
6926
- positions.map((pos) => /* @__PURE__ */ jsx(
6927
- "div",
6928
- {
6929
- className: "number-line-position-dot",
6930
- style: { left: `${toPercent(pos.value, min, max)}%` }
6931
- },
6932
- `dot-${pos.id}`
6933
- ))
6934
- ] }),
6935
- /* @__PURE__ */ jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
6936
- const hasInputAtTick = inputTickSet.has(tick);
6937
- return /* @__PURE__ */ jsx(
6938
- "span",
6939
- {
6940
- className: "number-line-tick-label",
6941
- style: { left: `${toPercent(tick, min, max)}%`, visibility: hasInputAtTick ? "hidden" : "visible" },
6942
- children: tick
6943
- },
6944
- `lbl-${tick}`
6945
- );
6946
- }) })
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
+ )
6947
7393
  ]
6948
7394
  }
6949
7395
  );
@@ -7171,8 +7617,7 @@ const ExerciseRenderer = forwardRef(({
7171
7617
  }
7172
7618
  }), [json, values, onSubmit]);
7173
7619
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
7174
- const newValues = { ...values, [widgetId]: value };
7175
- setValues(newValues);
7620
+ setValues((prev) => ({ ...prev, [widgetId]: value }));
7176
7621
  setDroppedSources((prev) => {
7177
7622
  const next = new Map(prev);
7178
7623
  if (fromDrop && value !== "" && sourceOptionId) {
@@ -7182,7 +7627,7 @@ const ExerciseRenderer = forwardRef(({
7182
7627
  }
7183
7628
  return next;
7184
7629
  });
7185
- onChange?.(newValues);
7630
+ onChange?.({ ...values, [widgetId]: value });
7186
7631
  };
7187
7632
  const handleSubmit = () => {
7188
7633
  onSubmit?.(values);
@@ -7406,6 +7851,10 @@ export {
7406
7851
  LexerError,
7407
7852
  LineBlockSchema,
7408
7853
  LinebreakSchema,
7854
+ NumberLineCounterItemSchema,
7855
+ NumberLineHighlightItemSchema,
7856
+ NumberLineItemSchema,
7857
+ NumberLinePositionItemSchema,
7409
7858
  NumericKeypadJSONSchema,
7410
7859
  NumericKeypadToolSchema,
7411
7860
  OperatorItemSchema,
@@ -7479,7 +7928,7 @@ export {
7479
7928
  isToolNode,
7480
7929
  isValidExpression,
7481
7930
  isValidationNode,
7482
- isWidgetRef$1 as isWidgetRef,
7931
+ isWidgetRef,
7483
7932
  parse,
7484
7933
  parseSafe,
7485
7934
  renderExercise,