@mathscan/math-exercise-engine 1.0.11 → 1.0.13
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.d.ts +2343 -25
- package/dist/index.js +981 -280
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +982 -281
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +396 -32
- package/package.json +1 -1
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";
|
|
@@ -21,6 +21,8 @@ const AST_TYPES = {
|
|
|
21
21
|
BOLD_TEXT: "BoldText",
|
|
22
22
|
/** Inline input widget (string or numeric) */
|
|
23
23
|
INLINE_INPUT: "InlineInput",
|
|
24
|
+
/** Inline fraction widget (numerator/denominator with optional inputs) */
|
|
25
|
+
FRACTION: "Fraction",
|
|
24
26
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
25
27
|
// Blocks
|
|
26
28
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -38,6 +40,10 @@ const AST_TYPES = {
|
|
|
38
40
|
NUMBER_LINE_BLOCK: "NumberLineBlock",
|
|
39
41
|
/** A position marker on the number line */
|
|
40
42
|
NUMBER_LINE_POSITION: "NumberLinePosition",
|
|
43
|
+
/** A dynamic subdivision counter embedded in a number line */
|
|
44
|
+
NUMBER_LINE_COUNTER: "NumberLineCounter",
|
|
45
|
+
/** A highlighted range on the number line axis */
|
|
46
|
+
NUMBER_LINE_HIGHLIGHT: "NumberLineHighlight",
|
|
41
47
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
42
48
|
// Tools
|
|
43
49
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -94,6 +100,10 @@ const JSON_TYPES = {
|
|
|
94
100
|
NUMBER_LINE: "number-line",
|
|
95
101
|
/** Position marker within a number line */
|
|
96
102
|
NUMBER_LINE_POSITION: "number-line-position",
|
|
103
|
+
/** Dynamic subdivision counter embedded in a number line */
|
|
104
|
+
NUMBER_LINE_COUNTER: "number-line-counter",
|
|
105
|
+
/** Highlighted range within a number line */
|
|
106
|
+
NUMBER_LINE_HIGHLIGHT: "number-line-highlight",
|
|
97
107
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
98
108
|
// Widget Types
|
|
99
109
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -104,7 +114,9 @@ const JSON_TYPES = {
|
|
|
104
114
|
/** Radio button options widget */
|
|
105
115
|
RADIO_OPTIONS: "radio-options",
|
|
106
116
|
/** Numeric keypad widget */
|
|
107
|
-
NUMERIC_KEYPAD: "numeric-keypad"
|
|
117
|
+
NUMERIC_KEYPAD: "numeric-keypad",
|
|
118
|
+
/** Inline fraction widget */
|
|
119
|
+
FRACTION: "fraction"
|
|
108
120
|
};
|
|
109
121
|
const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
|
|
110
122
|
const VALIDATION_MODES = {
|
|
@@ -120,8 +132,23 @@ const INPUT_TYPES = {
|
|
|
120
132
|
NUMERIC: "numeric"
|
|
121
133
|
};
|
|
122
134
|
const NUMBER_LINE_STYLES = ["arrow-right", "arrow-left", "arrow-both", "line"];
|
|
135
|
+
const INTERNAL_PRECISION = 9;
|
|
136
|
+
const DISPLAY_PRECISION = 2;
|
|
123
137
|
const SIDE_PAD = 7;
|
|
124
138
|
const CSS_VAR_SIDE_PAD = "--nl-side-pad";
|
|
139
|
+
const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
|
|
140
|
+
const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
|
|
141
|
+
function getFractionKey(widgetId, part) {
|
|
142
|
+
return `${widgetId}__${part}`;
|
|
143
|
+
}
|
|
144
|
+
function isFractionKey(key2) {
|
|
145
|
+
return key2.endsWith("__num") || key2.endsWith("__den");
|
|
146
|
+
}
|
|
147
|
+
function parseFractionKey(key2) {
|
|
148
|
+
const match = key2.match(/^(.+)__(num|den)$/);
|
|
149
|
+
if (!match) return null;
|
|
150
|
+
return { widgetId: match[1], part: match[2] };
|
|
151
|
+
}
|
|
125
152
|
const Newline = createToken({
|
|
126
153
|
name: "Newline",
|
|
127
154
|
pattern: /\r?\n/,
|
|
@@ -215,6 +242,18 @@ const CELL = createToken({
|
|
|
215
242
|
name: "CELL",
|
|
216
243
|
pattern: /CELL/
|
|
217
244
|
});
|
|
245
|
+
const COUNTER = createToken({
|
|
246
|
+
name: "COUNTER",
|
|
247
|
+
pattern: /COUNTER/
|
|
248
|
+
});
|
|
249
|
+
const HIGHLIGHT = createToken({
|
|
250
|
+
name: "HIGHLIGHT",
|
|
251
|
+
pattern: /HIGHLIGHT(?!_TEXT)/
|
|
252
|
+
});
|
|
253
|
+
const FRACTION = createToken({
|
|
254
|
+
name: "FRACTION",
|
|
255
|
+
pattern: /FRACTION/
|
|
256
|
+
});
|
|
218
257
|
const NumberLiteral = createToken({
|
|
219
258
|
name: "NumberLiteral",
|
|
220
259
|
pattern: /-?\d+(\.\d+)?/
|
|
@@ -322,6 +361,9 @@ const allTokens = [
|
|
|
322
361
|
TABLE,
|
|
323
362
|
ROW,
|
|
324
363
|
CELL,
|
|
364
|
+
COUNTER,
|
|
365
|
+
HIGHLIGHT,
|
|
366
|
+
FRACTION,
|
|
325
367
|
// Literals
|
|
326
368
|
NumberLiteral,
|
|
327
369
|
StringLiteral,
|
|
@@ -429,10 +471,7 @@ class MathExerciseParser extends CstParser {
|
|
|
429
471
|
});
|
|
430
472
|
this.exerciseDocument = this.RULE("exerciseDocument", () => {
|
|
431
473
|
this.MANY(() => {
|
|
432
|
-
this.OR([
|
|
433
|
-
{ ALT: () => this.CONSUME(Newline) },
|
|
434
|
-
{ ALT: () => this.CONSUME(Space) }
|
|
435
|
-
]);
|
|
474
|
+
this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
|
|
436
475
|
});
|
|
437
476
|
this.OPTION(() => {
|
|
438
477
|
this.SUBRULE(this.question);
|
|
@@ -448,10 +487,7 @@ class MathExerciseParser extends CstParser {
|
|
|
448
487
|
this.SUBRULE(this.validation);
|
|
449
488
|
});
|
|
450
489
|
this.MANY4(() => {
|
|
451
|
-
this.OR2([
|
|
452
|
-
{ ALT: () => this.CONSUME2(Newline) },
|
|
453
|
-
{ ALT: () => this.CONSUME2(Space) }
|
|
454
|
-
]);
|
|
490
|
+
this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
|
|
455
491
|
});
|
|
456
492
|
});
|
|
457
493
|
this.question = this.RULE("question", () => {
|
|
@@ -460,10 +496,7 @@ class MathExerciseParser extends CstParser {
|
|
|
460
496
|
this.CONSUME(StringLiteral, { LABEL: "value" });
|
|
461
497
|
this.CONSUME(RBracket);
|
|
462
498
|
this.MANY(() => {
|
|
463
|
-
this.OR([
|
|
464
|
-
{ ALT: () => this.CONSUME(Newline) },
|
|
465
|
-
{ ALT: () => this.CONSUME(Space) }
|
|
466
|
-
]);
|
|
499
|
+
this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
|
|
467
500
|
});
|
|
468
501
|
});
|
|
469
502
|
this.contentBlock = this.RULE("contentBlock", () => {
|
|
@@ -485,77 +518,50 @@ class MathExerciseParser extends CstParser {
|
|
|
485
518
|
});
|
|
486
519
|
this.table = this.RULE("table", () => {
|
|
487
520
|
this.MANY(() => {
|
|
488
|
-
this.OR2([
|
|
489
|
-
{ ALT: () => this.CONSUME(Space) },
|
|
490
|
-
{ ALT: () => this.CONSUME(Newline) }
|
|
491
|
-
]);
|
|
521
|
+
this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
492
522
|
});
|
|
493
523
|
this.CONSUME(TABLE);
|
|
494
524
|
this.CONSUME(LBracket);
|
|
495
525
|
this.SUBRULE(this.parameterList);
|
|
496
526
|
this.CONSUME(RBracket);
|
|
497
527
|
this.MANY2(() => {
|
|
498
|
-
this.OR3([
|
|
499
|
-
{ ALT: () => this.CONSUME2(Space) },
|
|
500
|
-
{ ALT: () => this.CONSUME2(Newline) }
|
|
501
|
-
]);
|
|
528
|
+
this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
502
529
|
});
|
|
503
530
|
this.CONSUME(LBrace);
|
|
504
531
|
this.MANY3(() => {
|
|
505
|
-
this.OR4([
|
|
506
|
-
{ ALT: () => this.CONSUME3(Space) },
|
|
507
|
-
{ ALT: () => this.CONSUME3(Newline) }
|
|
508
|
-
]);
|
|
532
|
+
this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
509
533
|
});
|
|
510
534
|
this.AT_LEAST_ONE(() => {
|
|
511
535
|
this.SUBRULE(this.row);
|
|
512
536
|
});
|
|
513
537
|
this.CONSUME(RBrace);
|
|
514
538
|
this.MANY4(() => {
|
|
515
|
-
this.OR5([
|
|
516
|
-
{ ALT: () => this.CONSUME4(Newline) },
|
|
517
|
-
{ ALT: () => this.CONSUME4(Space) }
|
|
518
|
-
]);
|
|
539
|
+
this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
|
|
519
540
|
});
|
|
520
541
|
});
|
|
521
542
|
this.row = this.RULE("row", () => {
|
|
522
543
|
this.MANY(() => {
|
|
523
|
-
this.OR([
|
|
524
|
-
{ ALT: () => this.CONSUME(Space) },
|
|
525
|
-
{ ALT: () => this.CONSUME(Newline) }
|
|
526
|
-
]);
|
|
544
|
+
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
527
545
|
});
|
|
528
546
|
this.CONSUME(ROW);
|
|
529
547
|
this.MANY2(() => {
|
|
530
|
-
this.OR2([
|
|
531
|
-
{ ALT: () => this.CONSUME2(Space) },
|
|
532
|
-
{ ALT: () => this.CONSUME2(Newline) }
|
|
533
|
-
]);
|
|
548
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
534
549
|
});
|
|
535
550
|
this.CONSUME(LBrace);
|
|
536
551
|
this.MANY3(() => {
|
|
537
|
-
this.OR3([
|
|
538
|
-
{ ALT: () => this.CONSUME3(Space) },
|
|
539
|
-
{ ALT: () => this.CONSUME3(Newline) }
|
|
540
|
-
]);
|
|
552
|
+
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
541
553
|
});
|
|
542
554
|
this.AT_LEAST_ONE(() => {
|
|
543
555
|
this.SUBRULE(this.cell);
|
|
544
556
|
});
|
|
545
557
|
this.CONSUME(RBrace);
|
|
546
558
|
this.MANY4(() => {
|
|
547
|
-
this.OR4([
|
|
548
|
-
{ ALT: () => this.CONSUME4(Space) },
|
|
549
|
-
{ ALT: () => this.CONSUME4(Newline) }
|
|
550
|
-
]);
|
|
559
|
+
this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
|
|
551
560
|
});
|
|
552
561
|
});
|
|
553
562
|
this.cell = this.RULE("cell", () => {
|
|
554
563
|
this.MANY(() => {
|
|
555
|
-
this.OR([
|
|
556
|
-
{ ALT: () => this.CONSUME(Space) },
|
|
557
|
-
{ ALT: () => this.CONSUME(Newline) }
|
|
558
|
-
]);
|
|
564
|
+
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
559
565
|
});
|
|
560
566
|
this.CONSUME(CELL);
|
|
561
567
|
this.OPTION(() => {
|
|
@@ -564,10 +570,7 @@ class MathExerciseParser extends CstParser {
|
|
|
564
570
|
this.CONSUME(RBracket);
|
|
565
571
|
});
|
|
566
572
|
this.MANY2(() => {
|
|
567
|
-
this.OR2([
|
|
568
|
-
{ ALT: () => this.CONSUME2(Space) },
|
|
569
|
-
{ ALT: () => this.CONSUME2(Newline) }
|
|
570
|
-
]);
|
|
573
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
571
574
|
});
|
|
572
575
|
this.CONSUME(LBrace);
|
|
573
576
|
this.MANY3(() => {
|
|
@@ -578,70 +581,47 @@ class MathExerciseParser extends CstParser {
|
|
|
578
581
|
});
|
|
579
582
|
this.CONSUME(RBrace);
|
|
580
583
|
this.MANY4(() => {
|
|
581
|
-
this.OR4([
|
|
582
|
-
{ ALT: () => this.CONSUME4(Space) },
|
|
583
|
-
{ ALT: () => this.CONSUME4(Newline) }
|
|
584
|
-
]);
|
|
584
|
+
this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
|
|
585
585
|
});
|
|
586
586
|
});
|
|
587
587
|
this.numberLine = this.RULE("numberLine", () => {
|
|
588
588
|
this.MANY(() => {
|
|
589
|
-
this.OR2([
|
|
590
|
-
{ ALT: () => this.CONSUME(Space) },
|
|
591
|
-
{ ALT: () => this.CONSUME(Newline) }
|
|
592
|
-
]);
|
|
589
|
+
this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
593
590
|
});
|
|
594
591
|
this.CONSUME(NUMBER_LINE);
|
|
595
592
|
this.CONSUME(LBracket);
|
|
596
593
|
this.SUBRULE(this.parameterList);
|
|
597
594
|
this.CONSUME(RBracket);
|
|
598
595
|
this.MANY2(() => {
|
|
599
|
-
this.OR3([
|
|
600
|
-
{ ALT: () => this.CONSUME2(Space) },
|
|
601
|
-
{ ALT: () => this.CONSUME2(Newline) }
|
|
602
|
-
]);
|
|
596
|
+
this.OR3([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
603
597
|
});
|
|
604
598
|
this.CONSUME(LBrace);
|
|
605
599
|
this.MANY3(() => {
|
|
606
|
-
this.OR4([
|
|
607
|
-
{ ALT: () => this.CONSUME3(Space) },
|
|
608
|
-
{ ALT: () => this.CONSUME3(Newline) }
|
|
609
|
-
]);
|
|
600
|
+
this.OR4([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
610
601
|
});
|
|
611
602
|
this.MANY4(() => {
|
|
612
|
-
this.
|
|
603
|
+
this.OR6([
|
|
604
|
+
{ ALT: () => this.SUBRULE(this.position) },
|
|
605
|
+
{ ALT: () => this.SUBRULE(this.counter) },
|
|
606
|
+
{ ALT: () => this.SUBRULE(this.highlight) }
|
|
607
|
+
]);
|
|
613
608
|
});
|
|
614
609
|
this.CONSUME(RBrace);
|
|
615
610
|
this.MANY5(() => {
|
|
616
|
-
this.OR5([
|
|
617
|
-
{ ALT: () => this.CONSUME4(Newline) },
|
|
618
|
-
{ ALT: () => this.CONSUME4(Space) }
|
|
619
|
-
]);
|
|
611
|
+
this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
|
|
620
612
|
});
|
|
621
613
|
});
|
|
622
614
|
this.position = this.RULE("position", () => {
|
|
623
|
-
this.MANY(() => {
|
|
624
|
-
this.OR([
|
|
625
|
-
{ ALT: () => this.CONSUME(Space) },
|
|
626
|
-
{ ALT: () => this.CONSUME(Newline) }
|
|
627
|
-
]);
|
|
628
|
-
});
|
|
629
615
|
this.CONSUME(POSITION);
|
|
630
616
|
this.CONSUME(LBracket);
|
|
631
617
|
this.SUBRULE(this.parameterList);
|
|
632
618
|
this.CONSUME(RBracket);
|
|
633
619
|
this.MANY2(() => {
|
|
634
|
-
this.OR2([
|
|
635
|
-
{ ALT: () => this.CONSUME2(Space) },
|
|
636
|
-
{ ALT: () => this.CONSUME2(Newline) }
|
|
637
|
-
]);
|
|
620
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
638
621
|
});
|
|
639
622
|
this.CONSUME(LBrace);
|
|
640
623
|
this.MANY3(() => {
|
|
641
|
-
this.OR3([
|
|
642
|
-
{ ALT: () => this.CONSUME3(Space) },
|
|
643
|
-
{ ALT: () => this.CONSUME3(Newline) }
|
|
644
|
-
]);
|
|
624
|
+
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
645
625
|
});
|
|
646
626
|
this.MANY4(() => {
|
|
647
627
|
this.OR4([
|
|
@@ -651,10 +631,25 @@ class MathExerciseParser extends CstParser {
|
|
|
651
631
|
});
|
|
652
632
|
this.CONSUME(RBrace);
|
|
653
633
|
this.MANY5(() => {
|
|
654
|
-
this.OR5([
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
634
|
+
this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
this.counter = this.RULE("counter", () => {
|
|
638
|
+
this.CONSUME(COUNTER);
|
|
639
|
+
this.CONSUME(LBracket);
|
|
640
|
+
this.SUBRULE(this.parameterList);
|
|
641
|
+
this.CONSUME(RBracket);
|
|
642
|
+
this.MANY2(() => {
|
|
643
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
this.highlight = this.RULE("highlight", () => {
|
|
647
|
+
this.CONSUME(HIGHLIGHT);
|
|
648
|
+
this.CONSUME(LBracket);
|
|
649
|
+
this.SUBRULE(this.parameterList);
|
|
650
|
+
this.CONSUME(RBracket);
|
|
651
|
+
this.MANY2(() => {
|
|
652
|
+
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
658
653
|
});
|
|
659
654
|
});
|
|
660
655
|
this.line = this.RULE("line", () => {
|
|
@@ -703,6 +698,8 @@ class MathExerciseParser extends CstParser {
|
|
|
703
698
|
{ ALT: () => this.SUBRULE(this.colorText) },
|
|
704
699
|
// BOLD_TEXT (for bold text)
|
|
705
700
|
{ ALT: () => this.SUBRULE(this.boldText) },
|
|
701
|
+
// FRACTION (inline fraction widget)
|
|
702
|
+
{ ALT: () => this.SUBRULE(this.fraction) },
|
|
706
703
|
// W_S_I (inline input widget)
|
|
707
704
|
{ ALT: () => this.SUBRULE(this.inlineInput) }
|
|
708
705
|
]);
|
|
@@ -728,6 +725,12 @@ class MathExerciseParser extends CstParser {
|
|
|
728
725
|
this.SUBRULE(this.parameterList);
|
|
729
726
|
this.CONSUME(RBracket);
|
|
730
727
|
});
|
|
728
|
+
this.fraction = this.RULE("fraction", () => {
|
|
729
|
+
this.CONSUME(FRACTION);
|
|
730
|
+
this.CONSUME(LBracket);
|
|
731
|
+
this.SUBRULE(this.parameterList);
|
|
732
|
+
this.CONSUME(RBracket);
|
|
733
|
+
});
|
|
731
734
|
this.inlineInput = this.RULE("inlineInput", () => {
|
|
732
735
|
this.OR([
|
|
733
736
|
{ ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
|
|
@@ -751,28 +754,19 @@ class MathExerciseParser extends CstParser {
|
|
|
751
754
|
this.SUBRULE(this.stringList);
|
|
752
755
|
this.CONSUME(RBracket);
|
|
753
756
|
this.MANY(() => {
|
|
754
|
-
this.OR([
|
|
755
|
-
{ ALT: () => this.CONSUME(Newline) },
|
|
756
|
-
{ ALT: () => this.CONSUME(Space) }
|
|
757
|
-
]);
|
|
757
|
+
this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
|
|
758
758
|
});
|
|
759
759
|
});
|
|
760
760
|
this.radioButtons = this.RULE("radioButtons", () => {
|
|
761
761
|
this.MANY(() => {
|
|
762
|
-
this.OR([
|
|
763
|
-
{ ALT: () => this.CONSUME(Newline) },
|
|
764
|
-
{ ALT: () => this.CONSUME(Space) }
|
|
765
|
-
]);
|
|
762
|
+
this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
|
|
766
763
|
});
|
|
767
764
|
this.CONSUME(RADIO_BUTTONS_OPTIONS);
|
|
768
765
|
this.CONSUME(LBracket);
|
|
769
766
|
this.SUBRULE(this.parameterList);
|
|
770
767
|
this.CONSUME(RBracket);
|
|
771
768
|
this.MANY2(() => {
|
|
772
|
-
this.OR2([
|
|
773
|
-
{ ALT: () => this.CONSUME2(Newline) },
|
|
774
|
-
{ ALT: () => this.CONSUME2(Space) }
|
|
775
|
-
]);
|
|
769
|
+
this.OR2([{ ALT: () => this.CONSUME2(Newline) }, { ALT: () => this.CONSUME2(Space) }]);
|
|
776
770
|
});
|
|
777
771
|
});
|
|
778
772
|
this.numericKeypad = this.RULE("numericKeypad", () => {
|
|
@@ -783,10 +777,7 @@ class MathExerciseParser extends CstParser {
|
|
|
783
777
|
});
|
|
784
778
|
this.CONSUME(RBracket);
|
|
785
779
|
this.MANY(() => {
|
|
786
|
-
this.OR([
|
|
787
|
-
{ ALT: () => this.CONSUME(Newline) },
|
|
788
|
-
{ ALT: () => this.CONSUME(Space) }
|
|
789
|
-
]);
|
|
780
|
+
this.OR([{ ALT: () => this.CONSUME(Newline) }, { ALT: () => this.CONSUME(Space) }]);
|
|
790
781
|
});
|
|
791
782
|
});
|
|
792
783
|
this.validation = this.RULE("validation", () => {
|
|
@@ -922,13 +913,22 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
|
|
|
922
913
|
answer: z.union([z.string(), z.number()]).optional()
|
|
923
914
|
})
|
|
924
915
|
]);
|
|
916
|
+
const FractionInlineSchema = z.object({
|
|
917
|
+
type: z.literal(AST_TYPES.FRACTION),
|
|
918
|
+
name: z.string(),
|
|
919
|
+
numerator: z.number().optional(),
|
|
920
|
+
denominator: z.number().optional(),
|
|
921
|
+
answer: z.string().optional(),
|
|
922
|
+
simplified: z.boolean().default(true)
|
|
923
|
+
});
|
|
925
924
|
const InlineNodeSchema = z.union([
|
|
926
925
|
FreeTextInlineSchema,
|
|
927
926
|
SpaceInlineSchema,
|
|
928
927
|
HighlightedTextInlineSchema,
|
|
929
928
|
ColorTextInlineSchema,
|
|
930
929
|
BoldTextInlineSchema,
|
|
931
|
-
InlineInputWidgetSchema
|
|
930
|
+
InlineInputWidgetSchema,
|
|
931
|
+
FractionInlineSchema
|
|
932
932
|
]);
|
|
933
933
|
function createFreeText(value) {
|
|
934
934
|
return FreeTextInlineSchema.parse({
|
|
@@ -981,6 +981,13 @@ function createNumericInput(name, digits, answer) {
|
|
|
981
981
|
answer
|
|
982
982
|
});
|
|
983
983
|
}
|
|
984
|
+
function createFraction(name, options = {}) {
|
|
985
|
+
return FractionInlineSchema.parse({
|
|
986
|
+
type: AST_TYPES.FRACTION,
|
|
987
|
+
name,
|
|
988
|
+
...options
|
|
989
|
+
});
|
|
990
|
+
}
|
|
984
991
|
function isFreeText(value) {
|
|
985
992
|
return FreeTextInlineSchema.safeParse(value).success;
|
|
986
993
|
}
|
|
@@ -990,6 +997,9 @@ function isSpace(value) {
|
|
|
990
997
|
function isInlineInput(value) {
|
|
991
998
|
return InlineInputWidgetSchema.safeParse(value).success;
|
|
992
999
|
}
|
|
1000
|
+
function isFraction(value) {
|
|
1001
|
+
return FractionInlineSchema.safeParse(value).success;
|
|
1002
|
+
}
|
|
993
1003
|
function isInlineNode(value) {
|
|
994
1004
|
return InlineNodeSchema.safeParse(value).success;
|
|
995
1005
|
}
|
|
@@ -1021,7 +1031,7 @@ function isLineBlock(value) {
|
|
|
1021
1031
|
}
|
|
1022
1032
|
const NumberLinePositionSchema = z.object({
|
|
1023
1033
|
type: z.literal(AST_TYPES.NUMBER_LINE_POSITION),
|
|
1024
|
-
value: z.number(),
|
|
1034
|
+
value: z.number().finite({ message: "POSITION: value must be a finite number" }),
|
|
1025
1035
|
inlines: z.array(InlineNodeSchema).min(1)
|
|
1026
1036
|
});
|
|
1027
1037
|
function createNumberLinePosition(value, inlines) {
|
|
@@ -1041,10 +1051,50 @@ function createNumberLinePosition(value, inlines) {
|
|
|
1041
1051
|
inlines
|
|
1042
1052
|
});
|
|
1043
1053
|
}
|
|
1054
|
+
const NumberLineCounterSchema = z.object({
|
|
1055
|
+
type: z.literal(AST_TYPES.NUMBER_LINE_COUNTER),
|
|
1056
|
+
min: z.number().int().min(1, { message: "COUNTER: min must be >= 1" }),
|
|
1057
|
+
max: z.number().int().positive({ message: "COUNTER: max must be > 0" }),
|
|
1058
|
+
default: z.number().int().nonnegative().optional(),
|
|
1059
|
+
name: z.string().optional()
|
|
1060
|
+
}).refine((d) => d.min <= d.max, {
|
|
1061
|
+
message: "COUNTER: min must be <= max"
|
|
1062
|
+
});
|
|
1063
|
+
function createNumberLineCounter(max, min, defaultValue2, name) {
|
|
1064
|
+
return NumberLineCounterSchema.parse({
|
|
1065
|
+
type: AST_TYPES.NUMBER_LINE_COUNTER,
|
|
1066
|
+
min: min ?? 1,
|
|
1067
|
+
max,
|
|
1068
|
+
...defaultValue2 !== void 0 && { default: defaultValue2 },
|
|
1069
|
+
...name !== void 0 && { name }
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
const NumberLineHighlightSchema = z.object({
|
|
1073
|
+
type: z.literal(AST_TYPES.NUMBER_LINE_HIGHLIGHT),
|
|
1074
|
+
from: z.number().finite({ message: "HIGHLIGHT: from must be a finite number" }),
|
|
1075
|
+
to: z.number().finite({ message: "HIGHLIGHT: to must be a finite number" }),
|
|
1076
|
+
color: z.enum(NUMBER_LINE_HIGHLIGHT_COLORS, {
|
|
1077
|
+
errorMap: () => ({
|
|
1078
|
+
message: `HIGHLIGHT: color must be one of ${NUMBER_LINE_HIGHLIGHT_COLORS.join(", ")}`
|
|
1079
|
+
})
|
|
1080
|
+
}).optional(),
|
|
1081
|
+
label: z.string().optional()
|
|
1082
|
+
}).refine((d) => d.from < d.to, {
|
|
1083
|
+
message: "HIGHLIGHT: from must be less than to"
|
|
1084
|
+
});
|
|
1085
|
+
function createNumberLineHighlight(from, to, color, label) {
|
|
1086
|
+
return NumberLineHighlightSchema.parse({
|
|
1087
|
+
type: AST_TYPES.NUMBER_LINE_HIGHLIGHT,
|
|
1088
|
+
from,
|
|
1089
|
+
to,
|
|
1090
|
+
...color !== void 0 && { color },
|
|
1091
|
+
...label !== void 0 && { label }
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1044
1094
|
const NumberLineBlockSchema = z.object({
|
|
1045
1095
|
type: z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
|
|
1046
|
-
min: z.number(),
|
|
1047
|
-
max: z.number(),
|
|
1096
|
+
min: z.number().finite({ message: "NUMBER_LINE: min must be a finite number" }),
|
|
1097
|
+
max: z.number().finite({ message: "NUMBER_LINE: max must be a finite number" }),
|
|
1048
1098
|
step: z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
|
|
1049
1099
|
minorTicks: z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
|
|
1050
1100
|
style: z.enum(NUMBER_LINE_STYLES, {
|
|
@@ -1052,6 +1102,13 @@ const NumberLineBlockSchema = z.object({
|
|
|
1052
1102
|
message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
|
|
1053
1103
|
})
|
|
1054
1104
|
}).optional(),
|
|
1105
|
+
labelVisibility: z.enum(NUMBER_LINE_LABELS, {
|
|
1106
|
+
errorMap: () => ({
|
|
1107
|
+
message: `NUMBER_LINE: labelVisibility must be one of ${NUMBER_LINE_LABELS.join(", ")}`
|
|
1108
|
+
})
|
|
1109
|
+
}).optional(),
|
|
1110
|
+
counter: NumberLineCounterSchema.optional(),
|
|
1111
|
+
highlights: z.array(NumberLineHighlightSchema).optional(),
|
|
1055
1112
|
positions: z.array(NumberLinePositionSchema)
|
|
1056
1113
|
}).refine((data) => data.min < data.max, {
|
|
1057
1114
|
message: "NUMBER_LINE: min must be less than max"
|
|
@@ -1072,8 +1129,24 @@ const NumberLineBlockSchema = z.object({
|
|
|
1072
1129
|
}
|
|
1073
1130
|
seen.add(pos.value);
|
|
1074
1131
|
}
|
|
1132
|
+
for (const h of data.highlights ?? []) {
|
|
1133
|
+
if (h.from < data.min || h.to > data.max) {
|
|
1134
|
+
ctx.addIssue({
|
|
1135
|
+
code: z.ZodIssueCode.custom,
|
|
1136
|
+
message: `NUMBER_LINE: HIGHLIGHT range [${h.from}, ${h.to}] must be within [${data.min}, ${data.max}]`
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (data.counter?.default !== void 0) {
|
|
1141
|
+
if (data.counter.default < data.counter.min || data.counter.default > data.counter.max) {
|
|
1142
|
+
ctx.addIssue({
|
|
1143
|
+
code: z.ZodIssueCode.custom,
|
|
1144
|
+
message: `NUMBER_LINE: COUNTER default (${data.counter.default}) must be within [${data.counter.min}, ${data.counter.max}]`
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1075
1148
|
});
|
|
1076
|
-
function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
|
|
1149
|
+
function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
|
|
1077
1150
|
return NumberLineBlockSchema.parse({
|
|
1078
1151
|
type: AST_TYPES.NUMBER_LINE_BLOCK,
|
|
1079
1152
|
min,
|
|
@@ -1081,6 +1154,9 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
|
|
|
1081
1154
|
step,
|
|
1082
1155
|
minorTicks,
|
|
1083
1156
|
style,
|
|
1157
|
+
labelVisibility,
|
|
1158
|
+
counter,
|
|
1159
|
+
highlights,
|
|
1084
1160
|
positions
|
|
1085
1161
|
});
|
|
1086
1162
|
}
|
|
@@ -1291,8 +1367,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1291
1367
|
const step = params.step !== void 0 ? Number(params.step) : void 0;
|
|
1292
1368
|
const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
|
|
1293
1369
|
const style = params.style;
|
|
1370
|
+
const labelVisibility = params.labelVisibility;
|
|
1294
1371
|
const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
|
|
1295
|
-
|
|
1372
|
+
const rawCounters = ctx.counter;
|
|
1373
|
+
if (rawCounters && rawCounters.length > 1) {
|
|
1374
|
+
throw new Error("NUMBER_LINE: only one COUNTER block is allowed");
|
|
1375
|
+
}
|
|
1376
|
+
const counter = rawCounters ? this.visit(rawCounters) : void 0;
|
|
1377
|
+
const highlights = ctx.highlight ? ctx.highlight.map((h) => this.visit(h)) : [];
|
|
1378
|
+
return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights);
|
|
1296
1379
|
}
|
|
1297
1380
|
position(ctx) {
|
|
1298
1381
|
const params = this.visit(ctx.parameterList);
|
|
@@ -1303,6 +1386,28 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1303
1386
|
const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
|
|
1304
1387
|
return createNumberLinePosition(value, inlines);
|
|
1305
1388
|
}
|
|
1389
|
+
counter(ctx) {
|
|
1390
|
+
const params = this.visit(ctx.parameterList);
|
|
1391
|
+
if (params.max === void 0) {
|
|
1392
|
+
throw new Error("COUNTER requires parameter: max");
|
|
1393
|
+
}
|
|
1394
|
+
const max = Number(params.max);
|
|
1395
|
+
const min = params.min !== void 0 ? Number(params.min) : void 0;
|
|
1396
|
+
const defaultValue2 = params.default !== void 0 ? Number(params.default) : void 0;
|
|
1397
|
+
const name = params.name;
|
|
1398
|
+
return createNumberLineCounter(max, min, defaultValue2, name);
|
|
1399
|
+
}
|
|
1400
|
+
highlight(ctx) {
|
|
1401
|
+
const params = this.visit(ctx.parameterList);
|
|
1402
|
+
if (params.from === void 0 || params.to === void 0) {
|
|
1403
|
+
throw new Error("HIGHLIGHT requires parameters: from and to");
|
|
1404
|
+
}
|
|
1405
|
+
const from = Number(params.from);
|
|
1406
|
+
const to = Number(params.to);
|
|
1407
|
+
const color = params.color;
|
|
1408
|
+
const label = params.label;
|
|
1409
|
+
return createNumberLineHighlight(from, to, color, label);
|
|
1410
|
+
}
|
|
1306
1411
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1307
1412
|
// Line Block
|
|
1308
1413
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1387,6 +1492,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1387
1492
|
if (ctx.boldText) {
|
|
1388
1493
|
return this.visit(ctx.boldText);
|
|
1389
1494
|
}
|
|
1495
|
+
if (ctx.fraction) {
|
|
1496
|
+
return this.visit(ctx.fraction);
|
|
1497
|
+
}
|
|
1390
1498
|
if (ctx.inlineInput) {
|
|
1391
1499
|
return this.visit(ctx.inlineInput);
|
|
1392
1500
|
}
|
|
@@ -1420,6 +1528,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1420
1528
|
}
|
|
1421
1529
|
return createBoldText(value, highlight);
|
|
1422
1530
|
}
|
|
1531
|
+
fraction(ctx) {
|
|
1532
|
+
const params = this.visit(ctx.parameterList);
|
|
1533
|
+
const name = params.name;
|
|
1534
|
+
const numerator = params.numerator;
|
|
1535
|
+
const denominator = params.denominator;
|
|
1536
|
+
const answer = params.answer;
|
|
1537
|
+
const simplified = params.simplified !== void 0 ? params.simplified : void 0;
|
|
1538
|
+
return createFraction(name, { numerator, denominator, answer, simplified });
|
|
1539
|
+
}
|
|
1423
1540
|
inlineInput(ctx) {
|
|
1424
1541
|
const params = this.visit(ctx.parameterList);
|
|
1425
1542
|
const name = params.name;
|
|
@@ -1633,17 +1750,36 @@ const TableItemSchema = z.object({
|
|
|
1633
1750
|
const NumberLinePositionItemSchema = z.object({
|
|
1634
1751
|
id: z.string(),
|
|
1635
1752
|
type: z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
|
|
1636
|
-
value: z.number(),
|
|
1637
|
-
content: z.array(
|
|
1753
|
+
value: z.number().finite(),
|
|
1754
|
+
content: z.array(LayoutItemSchema)
|
|
1755
|
+
});
|
|
1756
|
+
const NumberLineCounterItemSchema = z.object({
|
|
1757
|
+
id: z.string(),
|
|
1758
|
+
type: z.literal(JSON_TYPES.NUMBER_LINE_COUNTER),
|
|
1759
|
+
min: z.number().int().min(1),
|
|
1760
|
+
max: z.number().int().positive(),
|
|
1761
|
+
default: z.number().int().nonnegative().optional(),
|
|
1762
|
+
name: z.string().optional()
|
|
1763
|
+
});
|
|
1764
|
+
const NumberLineHighlightItemSchema = z.object({
|
|
1765
|
+
id: z.string(),
|
|
1766
|
+
type: z.literal(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
|
|
1767
|
+
from: z.number().finite(),
|
|
1768
|
+
to: z.number().finite(),
|
|
1769
|
+
color: z.enum(NUMBER_LINE_HIGHLIGHT_COLORS).optional(),
|
|
1770
|
+
label: z.string().optional()
|
|
1638
1771
|
});
|
|
1639
1772
|
const NumberLineItemSchema = z.object({
|
|
1640
1773
|
id: z.string(),
|
|
1641
1774
|
type: z.literal(JSON_TYPES.NUMBER_LINE),
|
|
1642
|
-
min: z.number(),
|
|
1643
|
-
max: z.number(),
|
|
1775
|
+
min: z.number().finite(),
|
|
1776
|
+
max: z.number().finite(),
|
|
1644
1777
|
step: z.number().positive(),
|
|
1645
1778
|
minorTicks: z.number().int().nonnegative().optional(),
|
|
1646
1779
|
style: z.enum(NUMBER_LINE_STYLES).optional(),
|
|
1780
|
+
labelVisibility: z.enum(NUMBER_LINE_LABELS).optional(),
|
|
1781
|
+
counter: NumberLineCounterItemSchema.optional(),
|
|
1782
|
+
highlights: z.array(NumberLineHighlightItemSchema).optional(),
|
|
1647
1783
|
positions: z.array(NumberLinePositionItemSchema)
|
|
1648
1784
|
});
|
|
1649
1785
|
const StringInputJSONSchema = z.object({
|
|
@@ -1673,11 +1809,18 @@ const NumericKeypadJSONSchema = z.object({
|
|
|
1673
1809
|
includeOperators: z.boolean().optional(),
|
|
1674
1810
|
includeDecimal: z.boolean().optional()
|
|
1675
1811
|
});
|
|
1812
|
+
const FractionJSONSchema = z.object({
|
|
1813
|
+
type: z.literal(JSON_TYPES.FRACTION),
|
|
1814
|
+
numerator: z.number().nullable(),
|
|
1815
|
+
denominator: z.number().nullable(),
|
|
1816
|
+
simplified: z.boolean()
|
|
1817
|
+
});
|
|
1676
1818
|
const WidgetJSONSchema = z.union([
|
|
1677
1819
|
InlineInputJSONSchema,
|
|
1678
1820
|
DragOptionsJSONSchema,
|
|
1679
1821
|
RadioOptionsJSONSchema,
|
|
1680
|
-
NumericKeypadJSONSchema
|
|
1822
|
+
NumericKeypadJSONSchema,
|
|
1823
|
+
FractionJSONSchema
|
|
1681
1824
|
]);
|
|
1682
1825
|
const ValidationJSONSchema = z.object({
|
|
1683
1826
|
mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
|
|
@@ -1717,7 +1860,7 @@ function isQuestionText(item) {
|
|
|
1717
1860
|
function isTextItem$1(item) {
|
|
1718
1861
|
return item.type === JSON_TYPES.TEXT;
|
|
1719
1862
|
}
|
|
1720
|
-
function isWidgetRef
|
|
1863
|
+
function isWidgetRef(item) {
|
|
1721
1864
|
return item.type === JSON_TYPES.WIDGET_REF;
|
|
1722
1865
|
}
|
|
1723
1866
|
function isHighlightedText(item) {
|
|
@@ -1762,6 +1905,9 @@ function isRadioOptionsJSON(widget) {
|
|
|
1762
1905
|
function isNumericKeypadJSON(widget) {
|
|
1763
1906
|
return widget.type === JSON_TYPES.NUMERIC_KEYPAD;
|
|
1764
1907
|
}
|
|
1908
|
+
function isFractionJSON(widget) {
|
|
1909
|
+
return widget.type === JSON_TYPES.FRACTION;
|
|
1910
|
+
}
|
|
1765
1911
|
class LayoutIdGenerator {
|
|
1766
1912
|
constructor() {
|
|
1767
1913
|
this.counter = 0;
|
|
@@ -1826,6 +1972,17 @@ function toJSON(ast) {
|
|
|
1826
1972
|
answers[inline.name] = inline.answer;
|
|
1827
1973
|
}
|
|
1828
1974
|
}
|
|
1975
|
+
if (inline.type === AST_TYPES.FRACTION) {
|
|
1976
|
+
widgets[inline.name] = {
|
|
1977
|
+
type: JSON_TYPES.FRACTION,
|
|
1978
|
+
numerator: inline.numerator ?? null,
|
|
1979
|
+
denominator: inline.denominator ?? null,
|
|
1980
|
+
simplified: inline.simplified ?? true
|
|
1981
|
+
};
|
|
1982
|
+
if (inline.answer !== void 0) {
|
|
1983
|
+
answers[inline.name] = inline.answer;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1829
1986
|
}
|
|
1830
1987
|
for (const block of ast.lines) {
|
|
1831
1988
|
if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
@@ -1890,16 +2047,42 @@ function transformNumberLine(numberLine, idGenerator) {
|
|
|
1890
2047
|
content
|
|
1891
2048
|
};
|
|
1892
2049
|
});
|
|
2050
|
+
let counterItem;
|
|
2051
|
+
if (numberLine.counter !== void 0) {
|
|
2052
|
+
const c = numberLine.counter;
|
|
2053
|
+
counterItem = {
|
|
2054
|
+
id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_COUNTER),
|
|
2055
|
+
type: JSON_TYPES.NUMBER_LINE_COUNTER,
|
|
2056
|
+
min: c.min,
|
|
2057
|
+
max: c.max,
|
|
2058
|
+
...c.default !== void 0 && { default: c.default },
|
|
2059
|
+
...c.name !== void 0 && { name: c.name }
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
let highlights;
|
|
2063
|
+
if (numberLine.highlights !== void 0 && numberLine.highlights.length > 0) {
|
|
2064
|
+
highlights = numberLine.highlights.map((h) => ({
|
|
2065
|
+
id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_HIGHLIGHT),
|
|
2066
|
+
type: JSON_TYPES.NUMBER_LINE_HIGHLIGHT,
|
|
2067
|
+
from: h.from,
|
|
2068
|
+
to: h.to,
|
|
2069
|
+
...h.color !== void 0 && { color: h.color },
|
|
2070
|
+
...h.label !== void 0 && { label: h.label }
|
|
2071
|
+
}));
|
|
2072
|
+
}
|
|
1893
2073
|
const item = {
|
|
1894
2074
|
id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
|
|
1895
2075
|
type: JSON_TYPES.NUMBER_LINE,
|
|
1896
2076
|
min: numberLine.min,
|
|
1897
2077
|
max: numberLine.max,
|
|
1898
2078
|
step: numberLine.step,
|
|
2079
|
+
...numberLine.minorTicks !== void 0 && { minorTicks: numberLine.minorTicks },
|
|
2080
|
+
...numberLine.style !== void 0 && { style: numberLine.style },
|
|
2081
|
+
...numberLine.labelVisibility !== void 0 && { labelVisibility: numberLine.labelVisibility },
|
|
2082
|
+
...counterItem !== void 0 && { counter: counterItem },
|
|
2083
|
+
...highlights !== void 0 && { highlights },
|
|
1899
2084
|
positions
|
|
1900
2085
|
};
|
|
1901
|
-
if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
|
|
1902
|
-
if (numberLine.style !== void 0) item.style = numberLine.style;
|
|
1903
2086
|
return item;
|
|
1904
2087
|
}
|
|
1905
2088
|
function transformTable(table, idGenerator) {
|
|
@@ -1990,6 +2173,12 @@ function transformInline(inline, idGenerator) {
|
|
|
1990
2173
|
type: JSON_TYPES.WIDGET_REF,
|
|
1991
2174
|
widgetId: inline.name
|
|
1992
2175
|
};
|
|
2176
|
+
case AST_TYPES.FRACTION:
|
|
2177
|
+
return {
|
|
2178
|
+
id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
|
|
2179
|
+
type: JSON_TYPES.WIDGET_REF,
|
|
2180
|
+
widgetId: inline.name
|
|
2181
|
+
};
|
|
1993
2182
|
default:
|
|
1994
2183
|
throw new Error(`Unknown inline type: ${inline.type}`);
|
|
1995
2184
|
}
|
|
@@ -2022,6 +2211,50 @@ function extractVariables$1(expression, validVariables) {
|
|
|
2022
2211
|
}
|
|
2023
2212
|
return uniqueVars;
|
|
2024
2213
|
}
|
|
2214
|
+
function normalize(value) {
|
|
2215
|
+
if (typeof value === "number") {
|
|
2216
|
+
return String(Math.round(value * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
|
|
2217
|
+
}
|
|
2218
|
+
const trimmed = String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
|
|
2219
|
+
const asNum = Number(trimmed);
|
|
2220
|
+
if (trimmed !== "" && !isNaN(asNum)) {
|
|
2221
|
+
return String(Math.round(asNum * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
|
|
2222
|
+
}
|
|
2223
|
+
return trimmed;
|
|
2224
|
+
}
|
|
2225
|
+
const NL_DRAG_PREFIX = "\0nl-drag\0";
|
|
2226
|
+
const isNLDragEncoded = (v) => v.startsWith(NL_DRAG_PREFIX);
|
|
2227
|
+
const encodeNLDragValue = (label, position) => `${NL_DRAG_PREFIX}${label}@${String(position)}`;
|
|
2228
|
+
function isNLDragCorrect(encoded, expectedLabel, expectedPosition) {
|
|
2229
|
+
const payload = encoded.slice(NL_DRAG_PREFIX.length);
|
|
2230
|
+
const atIdx = payload.lastIndexOf("@");
|
|
2231
|
+
const label = normalize(payload.slice(0, atIdx));
|
|
2232
|
+
const position = normalize(payload.slice(atIdx + 1));
|
|
2233
|
+
return label === normalize(expectedLabel) && position === normalize(expectedPosition);
|
|
2234
|
+
}
|
|
2235
|
+
const math$1 = create(all);
|
|
2236
|
+
function collectNumberLines(items) {
|
|
2237
|
+
const result = [];
|
|
2238
|
+
for (const item of items) {
|
|
2239
|
+
if (item.type === JSON_TYPES.NUMBER_LINE) result.push(item);
|
|
2240
|
+
else if (item.type === JSON_TYPES.GROUP) result.push(...collectNumberLines(item.items));
|
|
2241
|
+
}
|
|
2242
|
+
return result;
|
|
2243
|
+
}
|
|
2244
|
+
function buildPositionMap(exercise) {
|
|
2245
|
+
const map = {};
|
|
2246
|
+
for (const nl of collectNumberLines(exercise.layout)) {
|
|
2247
|
+
for (const pos of nl.positions) {
|
|
2248
|
+
for (const c of pos.content) {
|
|
2249
|
+
if (isWidgetRef(c)) {
|
|
2250
|
+
map[c.widgetId] = pos.value;
|
|
2251
|
+
break;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return map;
|
|
2257
|
+
}
|
|
2025
2258
|
function validateSimple(exercise, userInputs) {
|
|
2026
2259
|
const details = {};
|
|
2027
2260
|
let allCorrect = true;
|
|
@@ -2035,10 +2268,13 @@ function validateSimple(exercise, userInputs) {
|
|
|
2035
2268
|
score: 0
|
|
2036
2269
|
};
|
|
2037
2270
|
}
|
|
2271
|
+
const positionMap = buildPositionMap(exercise);
|
|
2038
2272
|
for (const [widgetId, expected] of Object.entries(answers)) {
|
|
2039
2273
|
totalCount++;
|
|
2040
2274
|
const userValue = userInputs[widgetId];
|
|
2041
|
-
const
|
|
2275
|
+
const widget = exercise.widgets[widgetId];
|
|
2276
|
+
const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
|
|
2277
|
+
const isCorrect = isFractionLenient ? areFractionsEquivalent(userValue, expected) : areValuesEqual(userValue, expected, positionMap[widgetId]);
|
|
2042
2278
|
details[widgetId] = {
|
|
2043
2279
|
widgetId,
|
|
2044
2280
|
userValue,
|
|
@@ -2066,19 +2302,23 @@ function validateSimple(exercise, userInputs) {
|
|
|
2066
2302
|
score: totalCount > 0 ? correctCount / totalCount : 0
|
|
2067
2303
|
};
|
|
2068
2304
|
}
|
|
2069
|
-
function areValuesEqual(userValue, expected) {
|
|
2070
|
-
if (userValue === void 0 || userValue === null)
|
|
2071
|
-
|
|
2305
|
+
function areValuesEqual(userValue, expected, expectedPosition) {
|
|
2306
|
+
if (userValue === void 0 || userValue === null) return false;
|
|
2307
|
+
if (typeof userValue === "string" && isNLDragEncoded(userValue)) {
|
|
2308
|
+
if (expectedPosition === void 0) return false;
|
|
2309
|
+
return isNLDragCorrect(userValue, expected, expectedPosition);
|
|
2072
2310
|
}
|
|
2073
|
-
|
|
2074
|
-
const normalizedExpected = normalize(expected);
|
|
2075
|
-
return normalizedUser === normalizedExpected;
|
|
2311
|
+
return normalize(userValue) === normalize(expected);
|
|
2076
2312
|
}
|
|
2077
|
-
function
|
|
2078
|
-
if (
|
|
2079
|
-
|
|
2313
|
+
function areFractionsEquivalent(user, expected) {
|
|
2314
|
+
if (user === void 0 || user === null) return false;
|
|
2315
|
+
try {
|
|
2316
|
+
const userFrac = math$1.fraction(String(user));
|
|
2317
|
+
const expectedFrac = math$1.fraction(String(expected));
|
|
2318
|
+
return math$1.equal(userFrac, expectedFrac);
|
|
2319
|
+
} catch {
|
|
2320
|
+
return areValuesEqual(user, expected);
|
|
2080
2321
|
}
|
|
2081
|
-
return String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
|
|
2082
2322
|
}
|
|
2083
2323
|
const math = create(all);
|
|
2084
2324
|
const BLOCKED_FUNCTIONS = [
|
|
@@ -2233,7 +2473,7 @@ function validateExpression(exercise, userInputs) {
|
|
|
2233
2473
|
hasInvalidInput = true;
|
|
2234
2474
|
continue;
|
|
2235
2475
|
}
|
|
2236
|
-
const numValue = typeof rawValue === "number" ? rawValue :
|
|
2476
|
+
const numValue = typeof rawValue === "number" ? rawValue : parseFractionOrNumber(String(rawValue).trim());
|
|
2237
2477
|
if (!Number.isFinite(numValue)) {
|
|
2238
2478
|
details[varName] = {
|
|
2239
2479
|
widgetId: varName,
|
|
@@ -2328,6 +2568,39 @@ function findValidCombination(expression, ranges) {
|
|
|
2328
2568
|
}
|
|
2329
2569
|
return null;
|
|
2330
2570
|
}
|
|
2571
|
+
function parseFractionOrNumber(s) {
|
|
2572
|
+
const parts = s.split("/");
|
|
2573
|
+
if (parts.length === 2) {
|
|
2574
|
+
const num = parseFloat(parts[0]);
|
|
2575
|
+
const den = parseFloat(parts[1]);
|
|
2576
|
+
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
|
|
2577
|
+
return num / den;
|
|
2578
|
+
}
|
|
2579
|
+
return NaN;
|
|
2580
|
+
}
|
|
2581
|
+
return parseFloat(s);
|
|
2582
|
+
}
|
|
2583
|
+
function preprocessFractionValues(exercise, userInputs) {
|
|
2584
|
+
const result = { ...userInputs };
|
|
2585
|
+
for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
|
|
2586
|
+
if (widget.type !== JSON_TYPES.FRACTION) continue;
|
|
2587
|
+
const numKey = getFractionKey(widgetId, "num");
|
|
2588
|
+
const denKey = getFractionKey(widgetId, "den");
|
|
2589
|
+
const numEffective = widget.numerator !== null ? String(widget.numerator) : result[numKey] !== void 0 ? String(result[numKey]) : "";
|
|
2590
|
+
const denEffective = widget.denominator !== null ? String(widget.denominator) : result[denKey] !== void 0 ? String(result[denKey]) : "";
|
|
2591
|
+
if (numEffective !== "" && denEffective !== "") {
|
|
2592
|
+
result[widgetId] = `${numEffective}/${denEffective}`;
|
|
2593
|
+
}
|
|
2594
|
+
delete result[numKey];
|
|
2595
|
+
delete result[denKey];
|
|
2596
|
+
}
|
|
2597
|
+
for (const key2 of Object.keys(result)) {
|
|
2598
|
+
if (isFractionKey(key2)) {
|
|
2599
|
+
delete result[key2];
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return result;
|
|
2603
|
+
}
|
|
2331
2604
|
function validate(exercise, userInputs) {
|
|
2332
2605
|
if (!exercise.validation) {
|
|
2333
2606
|
return {
|
|
@@ -2335,12 +2608,13 @@ function validate(exercise, userInputs) {
|
|
|
2335
2608
|
details: {}
|
|
2336
2609
|
};
|
|
2337
2610
|
}
|
|
2611
|
+
const processedInputs = preprocessFractionValues(exercise, userInputs);
|
|
2338
2612
|
const validatedExercise = exercise;
|
|
2339
2613
|
switch (validatedExercise.validation.mode) {
|
|
2340
2614
|
case VALIDATION_MODES.SIMPLE:
|
|
2341
|
-
return validateSimple(validatedExercise,
|
|
2615
|
+
return validateSimple(validatedExercise, processedInputs);
|
|
2342
2616
|
case VALIDATION_MODES.EXPRESSION:
|
|
2343
|
-
return validateExpression(validatedExercise,
|
|
2617
|
+
return validateExpression(validatedExercise, processedInputs);
|
|
2344
2618
|
default:
|
|
2345
2619
|
return {
|
|
2346
2620
|
isCorrect: false,
|
|
@@ -6410,6 +6684,7 @@ const InlineInput = ({
|
|
|
6410
6684
|
widgetId,
|
|
6411
6685
|
value,
|
|
6412
6686
|
isDropped = false,
|
|
6687
|
+
validationStatus = null,
|
|
6413
6688
|
inputType,
|
|
6414
6689
|
length,
|
|
6415
6690
|
digits,
|
|
@@ -6427,6 +6702,7 @@ const InlineInput = ({
|
|
|
6427
6702
|
}, [handleChange]);
|
|
6428
6703
|
const expectedLength = inputType === "numeric" ? digits : length;
|
|
6429
6704
|
const dynamicWidth = `${(expectedLength || 5) * 0.8 + 1}em`;
|
|
6705
|
+
const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
|
|
6430
6706
|
return /* @__PURE__ */ jsx(
|
|
6431
6707
|
Droppable,
|
|
6432
6708
|
{
|
|
@@ -6439,7 +6715,7 @@ const InlineInput = ({
|
|
|
6439
6715
|
/* @__PURE__ */ jsx(
|
|
6440
6716
|
"button",
|
|
6441
6717
|
{
|
|
6442
|
-
className:
|
|
6718
|
+
className: `inline-input-chip${statusClass}`,
|
|
6443
6719
|
onClick: () => handleChange("", false),
|
|
6444
6720
|
title: "Click to remove",
|
|
6445
6721
|
children: value
|
|
@@ -6452,7 +6728,7 @@ const InlineInput = ({
|
|
|
6452
6728
|
{
|
|
6453
6729
|
id: widgetId + "-input",
|
|
6454
6730
|
type: "text",
|
|
6455
|
-
className:
|
|
6731
|
+
className: `inline-input${statusClass}`,
|
|
6456
6732
|
value,
|
|
6457
6733
|
onChange: (e) => handleChange(e.target.value, false),
|
|
6458
6734
|
onFocus: () => onFocus?.(widgetId),
|
|
@@ -6529,18 +6805,83 @@ const NumericKeypad = ({
|
|
|
6529
6805
|
includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
|
|
6530
6806
|
] });
|
|
6531
6807
|
};
|
|
6808
|
+
const FractionInput = ({
|
|
6809
|
+
widgetId,
|
|
6810
|
+
numerator,
|
|
6811
|
+
denominator,
|
|
6812
|
+
values,
|
|
6813
|
+
droppedInputs,
|
|
6814
|
+
validationStatus = null,
|
|
6815
|
+
onChange,
|
|
6816
|
+
onFocus
|
|
6817
|
+
}) => {
|
|
6818
|
+
const numKey = getFractionKey(widgetId, "num");
|
|
6819
|
+
const denKey = getFractionKey(widgetId, "den");
|
|
6820
|
+
const numValue = values[numKey] ?? "";
|
|
6821
|
+
const denValue = values[denKey] ?? "";
|
|
6822
|
+
const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
|
|
6823
|
+
const handleDrop = useCallback((subKey) => (item) => {
|
|
6824
|
+
if (item.type === "drag-option") {
|
|
6825
|
+
const data = item.data;
|
|
6826
|
+
onChange(subKey, data.value, true, String(item.id));
|
|
6827
|
+
}
|
|
6828
|
+
}, [onChange]);
|
|
6829
|
+
const renderSlot = (subKey, fixedValue, currentValue, label) => {
|
|
6830
|
+
if (fixedValue !== null) {
|
|
6831
|
+
return /* @__PURE__ */ jsx("span", { className: "fraction-fixed", children: fixedValue });
|
|
6832
|
+
}
|
|
6833
|
+
const isDroppedHere = droppedInputs.has(subKey);
|
|
6834
|
+
return /* @__PURE__ */ jsx(
|
|
6835
|
+
Droppable,
|
|
6836
|
+
{
|
|
6837
|
+
id: subKey,
|
|
6838
|
+
accepts: ["drag-option"],
|
|
6839
|
+
onDrop: handleDrop(subKey),
|
|
6840
|
+
className: "fraction-slot-droppable",
|
|
6841
|
+
children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsx(
|
|
6842
|
+
"button",
|
|
6843
|
+
{
|
|
6844
|
+
className: `inline-input-chip fraction-chip${statusClass}`,
|
|
6845
|
+
onClick: () => onChange(subKey, "", false),
|
|
6846
|
+
title: "Click to remove",
|
|
6847
|
+
children: currentValue
|
|
6848
|
+
}
|
|
6849
|
+
) : /* @__PURE__ */ jsx(
|
|
6850
|
+
"input",
|
|
6851
|
+
{
|
|
6852
|
+
id: `${subKey}-input`,
|
|
6853
|
+
type: "text",
|
|
6854
|
+
className: `inline-input fraction-input${statusClass}`,
|
|
6855
|
+
value: currentValue,
|
|
6856
|
+
onChange: (e) => onChange(subKey, e.target.value, false),
|
|
6857
|
+
onFocus: () => onFocus?.(subKey),
|
|
6858
|
+
maxLength: 4,
|
|
6859
|
+
"aria-label": label
|
|
6860
|
+
}
|
|
6861
|
+
)
|
|
6862
|
+
}
|
|
6863
|
+
);
|
|
6864
|
+
};
|
|
6865
|
+
return /* @__PURE__ */ jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
|
|
6866
|
+
/* @__PURE__ */ jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
|
|
6867
|
+
/* @__PURE__ */ jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
|
|
6868
|
+
/* @__PURE__ */ jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
|
|
6869
|
+
] });
|
|
6870
|
+
};
|
|
6532
6871
|
const RadioOptions = ({
|
|
6533
6872
|
name,
|
|
6534
6873
|
options,
|
|
6535
6874
|
value,
|
|
6875
|
+
validationStatus = null,
|
|
6536
6876
|
onChange
|
|
6537
6877
|
}) => {
|
|
6538
6878
|
return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
6539
6879
|
const isSelected = value === option;
|
|
6880
|
+
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
6540
6881
|
return /* @__PURE__ */ jsx(
|
|
6541
6882
|
"button",
|
|
6542
6883
|
{
|
|
6543
|
-
className: `radio-option${isSelected ? " selected" : ""}`,
|
|
6884
|
+
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
6544
6885
|
onClick: () => onChange(name, option),
|
|
6545
6886
|
type: "button",
|
|
6546
6887
|
children: option
|
|
@@ -6561,11 +6902,17 @@ function isRadioOptionsWidget(widget) {
|
|
|
6561
6902
|
function isNumericKeypadWidget(widget) {
|
|
6562
6903
|
return widget.type === "numeric-keypad";
|
|
6563
6904
|
}
|
|
6905
|
+
function isFractionWidget(widget) {
|
|
6906
|
+
return widget.type === "fraction";
|
|
6907
|
+
}
|
|
6564
6908
|
const WidgetRenderer = ({
|
|
6565
6909
|
widget,
|
|
6566
6910
|
widgetId,
|
|
6567
6911
|
value,
|
|
6912
|
+
values = {},
|
|
6568
6913
|
isDropped = false,
|
|
6914
|
+
validationStatus = null,
|
|
6915
|
+
droppedInputs = /* @__PURE__ */ new Set(),
|
|
6569
6916
|
onChange,
|
|
6570
6917
|
onInputFocus,
|
|
6571
6918
|
onKeypadChange
|
|
@@ -6577,6 +6924,7 @@ const WidgetRenderer = ({
|
|
|
6577
6924
|
widgetId,
|
|
6578
6925
|
value,
|
|
6579
6926
|
isDropped,
|
|
6927
|
+
validationStatus,
|
|
6580
6928
|
inputType: widget.inputType,
|
|
6581
6929
|
length: widget.inputType === "string" ? widget.length : void 0,
|
|
6582
6930
|
digits: widget.inputType === "numeric" ? widget.digits : void 0,
|
|
@@ -6602,6 +6950,7 @@ const WidgetRenderer = ({
|
|
|
6602
6950
|
name: widgetId,
|
|
6603
6951
|
options: widget.options,
|
|
6604
6952
|
value,
|
|
6953
|
+
validationStatus,
|
|
6605
6954
|
onChange
|
|
6606
6955
|
}
|
|
6607
6956
|
);
|
|
@@ -6618,9 +6967,27 @@ const WidgetRenderer = ({
|
|
|
6618
6967
|
}
|
|
6619
6968
|
);
|
|
6620
6969
|
}
|
|
6970
|
+
if (isFractionWidget(widget)) {
|
|
6971
|
+
return /* @__PURE__ */ jsx(
|
|
6972
|
+
FractionInput,
|
|
6973
|
+
{
|
|
6974
|
+
widgetId,
|
|
6975
|
+
numerator: widget.numerator,
|
|
6976
|
+
denominator: widget.denominator,
|
|
6977
|
+
values,
|
|
6978
|
+
droppedInputs,
|
|
6979
|
+
validationStatus,
|
|
6980
|
+
onChange,
|
|
6981
|
+
onFocus: onInputFocus
|
|
6982
|
+
}
|
|
6983
|
+
);
|
|
6984
|
+
}
|
|
6621
6985
|
return null;
|
|
6622
6986
|
};
|
|
6623
|
-
const
|
|
6987
|
+
const EPSILON = 10 ** -INTERNAL_PRECISION;
|
|
6988
|
+
const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
|
|
6989
|
+
const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
|
|
6990
|
+
const almostEqual = (a, b) => Math.abs(a - b) <= EPSILON;
|
|
6624
6991
|
const toPercent = (value, min, max) => {
|
|
6625
6992
|
const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
|
|
6626
6993
|
return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
|
|
@@ -6636,19 +7003,24 @@ const getMajorTicks = (min, max, step) => {
|
|
|
6636
7003
|
const count = Math.floor((max - min) / step);
|
|
6637
7004
|
const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
|
|
6638
7005
|
const lastTick = ticks[ticks.length - 1];
|
|
6639
|
-
if (
|
|
7006
|
+
if (!almostEqual(lastTick, max)) {
|
|
7007
|
+
ticks.push(max);
|
|
7008
|
+
} else {
|
|
7009
|
+
ticks[ticks.length - 1] = max;
|
|
7010
|
+
}
|
|
6640
7011
|
return ticks;
|
|
6641
7012
|
};
|
|
6642
7013
|
const getMinorTickValues = (majorTicks, minorTicks) => {
|
|
6643
|
-
if (minorTicks <= 0) return [];
|
|
7014
|
+
if (minorTicks <= 0 || majorTicks.length < 2) return [];
|
|
6644
7015
|
const result = [];
|
|
6645
|
-
|
|
6646
|
-
const
|
|
6647
|
-
const
|
|
7016
|
+
for (let i = 0; i < majorTicks.length - 1; i++) {
|
|
7017
|
+
const from = majorTicks[i];
|
|
7018
|
+
const to = majorTicks[i + 1];
|
|
7019
|
+
const interval = to - from;
|
|
6648
7020
|
for (let j = 1; j <= minorTicks; j++) {
|
|
6649
|
-
result.push(cleanFloat(
|
|
7021
|
+
result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
|
|
6650
7022
|
}
|
|
6651
|
-
}
|
|
7023
|
+
}
|
|
6652
7024
|
return result;
|
|
6653
7025
|
};
|
|
6654
7026
|
const getSnapTicks = (majorTicks, minorTicks) => [
|
|
@@ -6657,19 +7029,41 @@ const getSnapTicks = (majorTicks, minorTicks) => [
|
|
|
6657
7029
|
];
|
|
6658
7030
|
const snapToNearest = (raw, ticks) => {
|
|
6659
7031
|
if (ticks.length === 0) return cleanFloat(raw);
|
|
6660
|
-
|
|
6661
|
-
(best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best
|
|
6662
|
-
);
|
|
6663
|
-
return cleanFloat(snapped);
|
|
7032
|
+
return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
|
|
6664
7033
|
};
|
|
6665
|
-
const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
|
|
6666
7034
|
const getPositionWidgetId = (content) => {
|
|
6667
7035
|
for (const item of content) {
|
|
6668
7036
|
if (isWidgetRef(item)) return item.widgetId;
|
|
6669
7037
|
}
|
|
6670
7038
|
return null;
|
|
6671
7039
|
};
|
|
6672
|
-
const
|
|
7040
|
+
const toHighlightColor = (color) => `var(--nl-highlight-${color})`;
|
|
7041
|
+
const getBandGeometry = (h, min, max) => {
|
|
7042
|
+
const leftPct = toPercent(Math.min(h.from, h.to), min, max);
|
|
7043
|
+
const rightPct = toPercent(Math.max(h.from, h.to), min, max);
|
|
7044
|
+
return { leftPct, widthPct: rightPct - leftPct };
|
|
7045
|
+
};
|
|
7046
|
+
const HighlightBands = ({ highlights, min, max }) => {
|
|
7047
|
+
if (!highlights?.length) return null;
|
|
7048
|
+
return /* @__PURE__ */ jsx(Fragment, { children: highlights.map((h) => {
|
|
7049
|
+
const { leftPct, widthPct } = getBandGeometry(h, min, max);
|
|
7050
|
+
if (widthPct <= 0) return null;
|
|
7051
|
+
return /* @__PURE__ */ jsx(
|
|
7052
|
+
"div",
|
|
7053
|
+
{
|
|
7054
|
+
className: "number-line-highlight-band",
|
|
7055
|
+
"aria-hidden": "true",
|
|
7056
|
+
style: {
|
|
7057
|
+
left: `${leftPct}%`,
|
|
7058
|
+
width: `${widthPct}%`,
|
|
7059
|
+
...h.color && { "--nl-highlight-color": toHighlightColor(h.color) }
|
|
7060
|
+
}
|
|
7061
|
+
},
|
|
7062
|
+
h.id
|
|
7063
|
+
);
|
|
7064
|
+
}) });
|
|
7065
|
+
};
|
|
7066
|
+
const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
|
|
6673
7067
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
6674
7068
|
id: widgetId,
|
|
6675
7069
|
data: { type: "placed-chip", widgetId, label }
|
|
@@ -6677,23 +7071,49 @@ const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
|
|
|
6677
7071
|
return /* @__PURE__ */ jsx(
|
|
6678
7072
|
"div",
|
|
6679
7073
|
{
|
|
6680
|
-
className:
|
|
6681
|
-
style: { left: `${leftPercent}
|
|
6682
|
-
children: /* @__PURE__ */
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
7074
|
+
className: `number-line-placed-chip${isLanding ? " nl-chip-landing" : ""}${isDragging ? " nl-chip-dragging" : ""}`,
|
|
7075
|
+
style: { left: `${leftPercent}%` },
|
|
7076
|
+
children: /* @__PURE__ */ jsxs("div", { className: "number-line-placed-chip-inner", children: [
|
|
7077
|
+
/* @__PURE__ */ jsx(
|
|
7078
|
+
"button",
|
|
7079
|
+
{
|
|
7080
|
+
ref: setNodeRef,
|
|
7081
|
+
className: "number-line-placed-chip-btn",
|
|
7082
|
+
"aria-label": `${label}, drag to move`,
|
|
7083
|
+
...listeners,
|
|
7084
|
+
...attributes,
|
|
7085
|
+
children: label
|
|
7086
|
+
}
|
|
7087
|
+
),
|
|
7088
|
+
/* @__PURE__ */ jsx(
|
|
7089
|
+
"button",
|
|
7090
|
+
{
|
|
7091
|
+
type: "button",
|
|
7092
|
+
className: "number-line-placed-chip-remove",
|
|
7093
|
+
"aria-label": "Remove",
|
|
7094
|
+
onClick: onRemove,
|
|
7095
|
+
children: "✕"
|
|
7096
|
+
}
|
|
7097
|
+
)
|
|
7098
|
+
] })
|
|
6694
7099
|
}
|
|
6695
7100
|
);
|
|
6696
7101
|
};
|
|
7102
|
+
function findNearestFreeWidgetSlot(positions, axisValue, values, placedChips, excludeWid) {
|
|
7103
|
+
let best;
|
|
7104
|
+
let bestDist = Infinity;
|
|
7105
|
+
for (const pos of positions) {
|
|
7106
|
+
const wid = getPositionWidgetId(pos.content);
|
|
7107
|
+
if (!wid) continue;
|
|
7108
|
+
if (wid !== excludeWid && (values[wid] || placedChips.has(wid))) continue;
|
|
7109
|
+
const dist = Math.abs(pos.value - axisValue);
|
|
7110
|
+
if (dist < bestDist) {
|
|
7111
|
+
bestDist = dist;
|
|
7112
|
+
best = pos;
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
return best;
|
|
7116
|
+
}
|
|
6697
7117
|
const FreeDragAxis = ({
|
|
6698
7118
|
axisDropId,
|
|
6699
7119
|
min,
|
|
@@ -6704,17 +7124,51 @@ const FreeDragAxis = ({
|
|
|
6704
7124
|
showArrowRight,
|
|
6705
7125
|
positions,
|
|
6706
7126
|
values,
|
|
6707
|
-
onChange
|
|
7127
|
+
onChange,
|
|
7128
|
+
highlights,
|
|
7129
|
+
snapKey
|
|
6708
7130
|
}) => {
|
|
6709
7131
|
const axisRef = useRef(null);
|
|
6710
7132
|
const snapTicks = useMemo(
|
|
6711
7133
|
() => getSnapTicks(majorTicks, minorTicks),
|
|
6712
7134
|
[majorTicks, minorTicks]
|
|
6713
7135
|
);
|
|
6714
|
-
const
|
|
6715
|
-
|
|
7136
|
+
const minorValues = useMemo(
|
|
7137
|
+
() => getMinorTickValues(majorTicks, minorTicks),
|
|
7138
|
+
[majorTicks, minorTicks]
|
|
7139
|
+
);
|
|
7140
|
+
const [placedChips, setPlacedChips] = useState(/* @__PURE__ */ new Map());
|
|
7141
|
+
const [hoverSnap, setHoverSnap] = useState(null);
|
|
7142
|
+
const [landingChips, setLandingChips] = useState(/* @__PURE__ */ new Set());
|
|
7143
|
+
const landingTimersRef = useRef(/* @__PURE__ */ new Map());
|
|
7144
|
+
useEffect(() => () => landingTimersRef.current.forEach(clearTimeout), []);
|
|
7145
|
+
const onChangeRef = useRef(onChange);
|
|
7146
|
+
const placedChipsRef = useRef(placedChips);
|
|
7147
|
+
useEffect(() => {
|
|
7148
|
+
onChangeRef.current = onChange;
|
|
7149
|
+
}, [onChange]);
|
|
7150
|
+
useEffect(() => {
|
|
7151
|
+
placedChipsRef.current = placedChips;
|
|
7152
|
+
}, [placedChips]);
|
|
7153
|
+
const isFirstRender = useRef(true);
|
|
6716
7154
|
useEffect(() => {
|
|
6717
|
-
|
|
7155
|
+
if (isFirstRender.current) {
|
|
7156
|
+
isFirstRender.current = false;
|
|
7157
|
+
return;
|
|
7158
|
+
}
|
|
7159
|
+
for (const [widgetId, chip] of placedChipsRef.current) {
|
|
7160
|
+
onChangeRef.current(widgetId, "", false, chip.sourceId);
|
|
7161
|
+
}
|
|
7162
|
+
landingTimersRef.current.forEach(clearTimeout);
|
|
7163
|
+
landingTimersRef.current.clear();
|
|
7164
|
+
const empty = /* @__PURE__ */ new Map();
|
|
7165
|
+
placedChipsRef.current = empty;
|
|
7166
|
+
setPlacedChips(empty);
|
|
7167
|
+
setHoverSnap(null);
|
|
7168
|
+
setLandingChips(/* @__PURE__ */ new Set());
|
|
7169
|
+
}, [snapKey]);
|
|
7170
|
+
useEffect(() => {
|
|
7171
|
+
setPlacedChips((prev) => {
|
|
6718
7172
|
let changed = false;
|
|
6719
7173
|
const next = new Map(prev);
|
|
6720
7174
|
for (const [wid] of next) {
|
|
@@ -6723,21 +7177,13 @@ const FreeDragAxis = ({
|
|
|
6723
7177
|
changed = true;
|
|
6724
7178
|
}
|
|
6725
7179
|
}
|
|
6726
|
-
|
|
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
|
-
}
|
|
7180
|
+
if (changed) {
|
|
7181
|
+
placedChipsRef.current = next;
|
|
6736
7182
|
}
|
|
6737
7183
|
return changed ? next : prev;
|
|
6738
7184
|
});
|
|
6739
7185
|
}, [values]);
|
|
6740
|
-
const { setNodeRef
|
|
7186
|
+
const { setNodeRef } = useDroppable({ id: axisDropId });
|
|
6741
7187
|
const mergedRef = useCallback((node) => {
|
|
6742
7188
|
axisRef.current = node;
|
|
6743
7189
|
setNodeRef(node);
|
|
@@ -6745,91 +7191,163 @@ const FreeDragAxis = ({
|
|
|
6745
7191
|
const snapFromEvent = useCallback((translated) => {
|
|
6746
7192
|
if (!axisRef.current) return null;
|
|
6747
7193
|
const axisRect = axisRef.current.getBoundingClientRect();
|
|
7194
|
+
if (axisRect.width === 0) return null;
|
|
6748
7195
|
const chipCenterX = translated.left + translated.width / 2;
|
|
6749
7196
|
const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
|
|
6750
7197
|
return snapToNearest(raw, snapTicks);
|
|
6751
7198
|
}, [min, max, snapTicks]);
|
|
7199
|
+
const isTickOccupied = useCallback(
|
|
7200
|
+
(tick, excludeWid) => [...placedChipsRef.current.entries()].some(
|
|
7201
|
+
([wid, c]) => wid !== excludeWid && almostEqual(c.position, tick)
|
|
7202
|
+
),
|
|
7203
|
+
[]
|
|
7204
|
+
// reads placedChipsRef (a ref — stable, no dep needed)
|
|
7205
|
+
);
|
|
7206
|
+
const findNearestFreeWidgetSlotCb = useCallback(
|
|
7207
|
+
(axisValue, excludeWid) => findNearestFreeWidgetSlot(positions, axisValue, values, placedChipsRef.current, excludeWid),
|
|
7208
|
+
[positions, values]
|
|
7209
|
+
);
|
|
7210
|
+
const triggerLanding = useCallback((widgetId) => {
|
|
7211
|
+
setLandingChips((prev) => new Set(prev).add(widgetId));
|
|
7212
|
+
clearTimeout(landingTimersRef.current.get(widgetId));
|
|
7213
|
+
landingTimersRef.current.set(widgetId, setTimeout(() => {
|
|
7214
|
+
setLandingChips((prev) => {
|
|
7215
|
+
const s = new Set(prev);
|
|
7216
|
+
s.delete(widgetId);
|
|
7217
|
+
return s;
|
|
7218
|
+
});
|
|
7219
|
+
landingTimersRef.current.delete(widgetId);
|
|
7220
|
+
}, 300));
|
|
7221
|
+
}, []);
|
|
6752
7222
|
useDndMonitor({
|
|
7223
|
+
onDragMove(event) {
|
|
7224
|
+
if (!event.over || event.over.id !== axisDropId) {
|
|
7225
|
+
setHoverSnap(null);
|
|
7226
|
+
return;
|
|
7227
|
+
}
|
|
7228
|
+
const translated = event.active.rect.current.translated;
|
|
7229
|
+
if (!translated) return;
|
|
7230
|
+
setHoverSnap(snapFromEvent(translated));
|
|
7231
|
+
},
|
|
7232
|
+
onDragCancel() {
|
|
7233
|
+
setHoverSnap(null);
|
|
7234
|
+
},
|
|
6753
7235
|
onDragEnd(event) {
|
|
7236
|
+
setHoverSnap(null);
|
|
6754
7237
|
if (!event.over || event.over.id !== axisDropId) return;
|
|
6755
7238
|
if (!axisRef.current) return;
|
|
6756
7239
|
const translated = event.active.rect.current.translated;
|
|
6757
7240
|
if (!translated) return;
|
|
6758
|
-
const
|
|
6759
|
-
if (
|
|
7241
|
+
const rawPlaced = snapFromEvent(translated);
|
|
7242
|
+
if (rawPlaced === null) {
|
|
7243
|
+
if (process.env.NODE_ENV !== "production") {
|
|
7244
|
+
console.warn("[FreeDragAxis] snap calculation failed — axis may not be laid out yet");
|
|
7245
|
+
}
|
|
7246
|
+
return;
|
|
7247
|
+
}
|
|
7248
|
+
const placed = cleanFloat(rawPlaced);
|
|
6760
7249
|
const data = event.active.data.current;
|
|
6761
|
-
if (data
|
|
6762
|
-
|
|
7250
|
+
if (!data) return;
|
|
7251
|
+
if (data.type === "placed-chip") {
|
|
7252
|
+
const oldWidgetId = data.widgetId;
|
|
6763
7253
|
const label = data.label;
|
|
6764
|
-
const
|
|
6765
|
-
|
|
6766
|
-
|
|
7254
|
+
const sourceId2 = placedChipsRef.current.get(oldWidgetId)?.sourceId ?? oldWidgetId;
|
|
7255
|
+
if (isTickOccupied(placed, oldWidgetId)) return;
|
|
7256
|
+
const targetPos2 = findNearestFreeWidgetSlotCb(placed, oldWidgetId);
|
|
7257
|
+
if (!targetPos2) return;
|
|
7258
|
+
const newWidgetId = getPositionWidgetId(targetPos2.content);
|
|
7259
|
+
if (!newWidgetId) return;
|
|
7260
|
+
if (newWidgetId !== oldWidgetId) {
|
|
7261
|
+
onChangeRef.current(oldWidgetId, "", false, sourceId2);
|
|
7262
|
+
}
|
|
7263
|
+
onChangeRef.current(newWidgetId, encodeNLDragValue(label, placed), true, sourceId2);
|
|
7264
|
+
setPlacedChips((prev) => {
|
|
7265
|
+
const next = new Map(prev);
|
|
7266
|
+
if (newWidgetId !== oldWidgetId) next.delete(oldWidgetId);
|
|
7267
|
+
next.set(newWidgetId, { label, position: placed, sourceId: sourceId2 });
|
|
7268
|
+
placedChipsRef.current = next;
|
|
7269
|
+
return next;
|
|
7270
|
+
});
|
|
7271
|
+
triggerLanding(newWidgetId);
|
|
6767
7272
|
return;
|
|
6768
7273
|
}
|
|
6769
|
-
|
|
6770
|
-
if (
|
|
6771
|
-
const targetPos =
|
|
6772
|
-
const wid = getPositionWidgetId(pos.content);
|
|
6773
|
-
return wid && !values[wid];
|
|
6774
|
-
});
|
|
7274
|
+
if (data.type !== "drag-option") return;
|
|
7275
|
+
if (isTickOccupied(placed)) return;
|
|
7276
|
+
const targetPos = findNearestFreeWidgetSlotCb(placed);
|
|
6775
7277
|
if (!targetPos) return;
|
|
6776
7278
|
const targetWidgetId = getPositionWidgetId(targetPos.content);
|
|
6777
7279
|
if (!targetWidgetId) return;
|
|
6778
|
-
const chipLabel =
|
|
6779
|
-
const
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
7280
|
+
const chipLabel = data.data.value;
|
|
7281
|
+
const sourceId = String(event.active.id);
|
|
7282
|
+
onChangeRef.current(targetWidgetId, encodeNLDragValue(chipLabel, placed), true, sourceId);
|
|
7283
|
+
setPlacedChips((prev) => {
|
|
7284
|
+
const next = new Map(prev).set(targetWidgetId, { label: chipLabel, position: placed, sourceId });
|
|
7285
|
+
placedChipsRef.current = next;
|
|
7286
|
+
return next;
|
|
7287
|
+
});
|
|
7288
|
+
triggerLanding(targetWidgetId);
|
|
6783
7289
|
}
|
|
6784
7290
|
});
|
|
6785
7291
|
return /* @__PURE__ */ jsxs(
|
|
6786
7292
|
"div",
|
|
6787
7293
|
{
|
|
6788
7294
|
ref: mergedRef,
|
|
6789
|
-
className:
|
|
7295
|
+
className: "number-line-axis-row nl-axis-drag-mode",
|
|
6790
7296
|
children: [
|
|
6791
7297
|
/* @__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
|
-
|
|
7298
|
+
showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
|
|
7299
|
+
showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
|
|
7300
|
+
/* @__PURE__ */ jsx(HighlightBands, { highlights, min, max }),
|
|
7301
|
+
minorValues.map((v) => /* @__PURE__ */ jsx(
|
|
6795
7302
|
"div",
|
|
6796
7303
|
{
|
|
6797
|
-
className:
|
|
7304
|
+
className: `number-line-minor-tick${hoverSnap === v ? " nl-tick-hover" : ""}`,
|
|
6798
7305
|
style: { left: `${toPercent(v, min, max)}%` }
|
|
6799
7306
|
},
|
|
6800
|
-
`minor-${v}`
|
|
7307
|
+
`minor-${cleanFloat(v)}`
|
|
6801
7308
|
)),
|
|
6802
7309
|
majorTicks.map((tick) => /* @__PURE__ */ jsx(
|
|
6803
7310
|
"div",
|
|
6804
7311
|
{
|
|
6805
|
-
className:
|
|
7312
|
+
className: `number-line-major-tick${hoverSnap === tick ? " nl-tick-hover" : ""}`,
|
|
6806
7313
|
style: { left: `${toPercent(tick, min, max)}%` }
|
|
6807
7314
|
},
|
|
6808
|
-
`major-${tick}`
|
|
7315
|
+
`major-${cleanFloat(tick)}`
|
|
7316
|
+
)),
|
|
7317
|
+
hoverSnap !== null && /* @__PURE__ */ jsx(
|
|
7318
|
+
"div",
|
|
7319
|
+
{
|
|
7320
|
+
className: "nl-snap-preview",
|
|
7321
|
+
style: { left: `${toPercent(hoverSnap, min, max)}%` },
|
|
7322
|
+
"aria-hidden": "true"
|
|
7323
|
+
}
|
|
7324
|
+
),
|
|
7325
|
+
positions.filter((pos) => getPositionWidgetId(pos.content) === null).map((pos) => /* @__PURE__ */ jsx(
|
|
7326
|
+
"div",
|
|
7327
|
+
{
|
|
7328
|
+
className: "number-line-position-dot",
|
|
7329
|
+
style: { left: `${toPercent(pos.value, min, max)}%` }
|
|
7330
|
+
},
|
|
7331
|
+
`dot-${pos.id}`
|
|
6809
7332
|
)),
|
|
6810
7333
|
positions.map((pos) => {
|
|
6811
7334
|
const wid = getPositionWidgetId(pos.content);
|
|
6812
|
-
const
|
|
6813
|
-
|
|
6814
|
-
if (!wid || isNaN(dropPos) || !label) return null;
|
|
7335
|
+
const chip = wid ? placedChips.get(wid) : void 0;
|
|
7336
|
+
if (!wid || !chip) return null;
|
|
6815
7337
|
return /* @__PURE__ */ jsx(
|
|
6816
7338
|
PlacedChip,
|
|
6817
7339
|
{
|
|
6818
7340
|
widgetId: wid,
|
|
6819
|
-
label,
|
|
6820
|
-
leftPercent: toPercent(
|
|
7341
|
+
label: chip.label,
|
|
7342
|
+
leftPercent: toPercent(chip.position, min, max),
|
|
7343
|
+
isLanding: landingChips.has(wid),
|
|
6821
7344
|
onRemove: () => {
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
return
|
|
6828
|
-
});
|
|
6829
|
-
setDropSources((prev) => {
|
|
6830
|
-
const m = new Map(prev);
|
|
6831
|
-
m.delete(wid);
|
|
6832
|
-
return m;
|
|
7345
|
+
onChangeRef.current(wid, "", false, chip.sourceId);
|
|
7346
|
+
setPlacedChips((prev) => {
|
|
7347
|
+
const next = new Map(prev);
|
|
7348
|
+
next.delete(wid);
|
|
7349
|
+
placedChipsRef.current = next;
|
|
7350
|
+
return next;
|
|
6833
7351
|
});
|
|
6834
7352
|
}
|
|
6835
7353
|
},
|
|
@@ -6840,35 +7358,194 @@ const FreeDragAxis = ({
|
|
|
6840
7358
|
}
|
|
6841
7359
|
);
|
|
6842
7360
|
};
|
|
7361
|
+
const AxisTicks = ({
|
|
7362
|
+
min,
|
|
7363
|
+
max,
|
|
7364
|
+
majorTicks,
|
|
7365
|
+
minorValues,
|
|
7366
|
+
showArrowLeft,
|
|
7367
|
+
showArrowRight,
|
|
7368
|
+
highlights,
|
|
7369
|
+
positions
|
|
7370
|
+
}) => /* @__PURE__ */ jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
|
|
7371
|
+
/* @__PURE__ */ jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
|
|
7372
|
+
showArrowLeft && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
|
|
7373
|
+
showArrowRight && /* @__PURE__ */ jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
|
|
7374
|
+
/* @__PURE__ */ jsx(HighlightBands, { highlights, min, max }),
|
|
7375
|
+
minorValues.map((v) => /* @__PURE__ */ jsx(
|
|
7376
|
+
"div",
|
|
7377
|
+
{
|
|
7378
|
+
className: "number-line-minor-tick",
|
|
7379
|
+
"aria-hidden": "true",
|
|
7380
|
+
style: { left: `${toPercent(v, min, max)}%` }
|
|
7381
|
+
},
|
|
7382
|
+
`minor-${v}`
|
|
7383
|
+
)),
|
|
7384
|
+
majorTicks.map((tick) => /* @__PURE__ */ jsx(
|
|
7385
|
+
"div",
|
|
7386
|
+
{
|
|
7387
|
+
className: "number-line-major-tick",
|
|
7388
|
+
"aria-hidden": "true",
|
|
7389
|
+
style: { left: `${toPercent(tick, min, max)}%` }
|
|
7390
|
+
},
|
|
7391
|
+
`major-${tick}`
|
|
7392
|
+
)),
|
|
7393
|
+
positions.map((pos) => /* @__PURE__ */ jsx(
|
|
7394
|
+
"div",
|
|
7395
|
+
{
|
|
7396
|
+
className: "number-line-position-dot",
|
|
7397
|
+
"aria-hidden": "true",
|
|
7398
|
+
style: { left: `${toPercent(pos.value, min, max)}%` }
|
|
7399
|
+
},
|
|
7400
|
+
`dot-${pos.id}`
|
|
7401
|
+
))
|
|
7402
|
+
] });
|
|
7403
|
+
const isLabelVisible = (tick, min, max, labelVisibility) => {
|
|
7404
|
+
switch (labelVisibility) {
|
|
7405
|
+
case "none":
|
|
7406
|
+
return false;
|
|
7407
|
+
case "min-only":
|
|
7408
|
+
return tick === roundTick(min);
|
|
7409
|
+
case "max-only":
|
|
7410
|
+
return tick === roundTick(max);
|
|
7411
|
+
case "extremes":
|
|
7412
|
+
return tick === roundTick(min) || tick === roundTick(max);
|
|
7413
|
+
default:
|
|
7414
|
+
return true;
|
|
7415
|
+
}
|
|
7416
|
+
};
|
|
7417
|
+
const AxisLabels = ({
|
|
7418
|
+
majorTicks,
|
|
7419
|
+
min,
|
|
7420
|
+
max,
|
|
7421
|
+
labelVisibility,
|
|
7422
|
+
inputTickSet
|
|
7423
|
+
}) => /* @__PURE__ */ jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
|
|
7424
|
+
const visible = !inputTickSet.has(tick) && isLabelVisible(tick, min, max, labelVisibility);
|
|
7425
|
+
return /* @__PURE__ */ jsx(
|
|
7426
|
+
"span",
|
|
7427
|
+
{
|
|
7428
|
+
className: "number-line-tick-label",
|
|
7429
|
+
style: {
|
|
7430
|
+
left: `${toPercent(tick, min, max)}%`,
|
|
7431
|
+
visibility: visible ? "visible" : "hidden"
|
|
7432
|
+
},
|
|
7433
|
+
children: tick
|
|
7434
|
+
},
|
|
7435
|
+
`lbl-${tick}`
|
|
7436
|
+
);
|
|
7437
|
+
}) });
|
|
7438
|
+
const HighlightLabelsRow = ({ highlights, min, max }) => {
|
|
7439
|
+
if (!highlights?.some((h) => h.label)) return null;
|
|
7440
|
+
return /* @__PURE__ */ jsx("div", { className: "number-line-highlight-labels-row", "aria-hidden": "true", children: highlights.map((h) => {
|
|
7441
|
+
if (!h.label) return null;
|
|
7442
|
+
const centerPct = (toPercent(h.from, min, max) + toPercent(h.to, min, max)) / 2;
|
|
7443
|
+
return /* @__PURE__ */ jsx(
|
|
7444
|
+
"span",
|
|
7445
|
+
{
|
|
7446
|
+
className: "number-line-highlight-label",
|
|
7447
|
+
style: { left: `${centerPct}%` },
|
|
7448
|
+
children: h.label
|
|
7449
|
+
},
|
|
7450
|
+
`hl-lbl-${h.id}`
|
|
7451
|
+
);
|
|
7452
|
+
}) });
|
|
7453
|
+
};
|
|
7454
|
+
const CounterControls = ({
|
|
7455
|
+
counter,
|
|
7456
|
+
count,
|
|
7457
|
+
onDecrement,
|
|
7458
|
+
onIncrement
|
|
7459
|
+
}) => /* @__PURE__ */ jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
|
|
7460
|
+
/* @__PURE__ */ jsxs("div", { className: "number-line-counter-controls", children: [
|
|
7461
|
+
/* @__PURE__ */ jsx(
|
|
7462
|
+
"button",
|
|
7463
|
+
{
|
|
7464
|
+
type: "button",
|
|
7465
|
+
className: "number-line-counter-btn",
|
|
7466
|
+
"aria-label": "Decrease",
|
|
7467
|
+
disabled: count <= counter.min,
|
|
7468
|
+
onClick: onDecrement,
|
|
7469
|
+
children: "−"
|
|
7470
|
+
}
|
|
7471
|
+
),
|
|
7472
|
+
/* @__PURE__ */ jsx("span", { className: "number-line-counter-value", "data-testid": "counter-value", children: count }),
|
|
7473
|
+
/* @__PURE__ */ jsx(
|
|
7474
|
+
"button",
|
|
7475
|
+
{
|
|
7476
|
+
type: "button",
|
|
7477
|
+
className: "number-line-counter-btn",
|
|
7478
|
+
"aria-label": "Increase",
|
|
7479
|
+
disabled: count >= counter.max,
|
|
7480
|
+
onClick: onIncrement,
|
|
7481
|
+
children: "+"
|
|
7482
|
+
}
|
|
7483
|
+
)
|
|
7484
|
+
] }),
|
|
7485
|
+
counter.name && /* @__PURE__ */ jsx("span", { className: "number-line-counter-label", children: counter.name })
|
|
7486
|
+
] });
|
|
6843
7487
|
const NumberLineRenderer = ({
|
|
6844
7488
|
item,
|
|
6845
7489
|
widgets,
|
|
6846
7490
|
values,
|
|
6847
7491
|
droppedInputs,
|
|
7492
|
+
validationDetails,
|
|
6848
7493
|
onChange,
|
|
6849
7494
|
onInputFocus
|
|
6850
7495
|
}) => {
|
|
6851
|
-
const {
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
7496
|
+
const {
|
|
7497
|
+
min,
|
|
7498
|
+
max,
|
|
7499
|
+
step,
|
|
7500
|
+
minorTicks = 0,
|
|
7501
|
+
style: lineStyle = "arrow-right",
|
|
7502
|
+
labelVisibility,
|
|
7503
|
+
highlights,
|
|
7504
|
+
counter,
|
|
7505
|
+
positions
|
|
7506
|
+
} = item;
|
|
7507
|
+
const [counterCount, setCounterCount] = useState(
|
|
7508
|
+
counter !== void 0 ? counter.default ?? counter.min : 0
|
|
7509
|
+
);
|
|
7510
|
+
useEffect(() => {
|
|
7511
|
+
setCounterCount(counter !== void 0 ? counter.default ?? counter.min : 0);
|
|
7512
|
+
}, [counter]);
|
|
7513
|
+
const effectiveStep = useMemo(() => {
|
|
7514
|
+
if (counter !== void 0 && counterCount > 0) {
|
|
7515
|
+
return cleanFloat((max - min) / counterCount);
|
|
7516
|
+
}
|
|
7517
|
+
return step;
|
|
7518
|
+
}, [counter, counterCount, min, max, step]);
|
|
7519
|
+
const majorTicks = useMemo(
|
|
7520
|
+
() => getMajorTicks(min, max, effectiveStep).map(roundTick),
|
|
7521
|
+
[min, max, effectiveStep]
|
|
7522
|
+
);
|
|
7523
|
+
const minorValues = useMemo(
|
|
7524
|
+
() => getMinorTickValues(majorTicks, minorTicks),
|
|
7525
|
+
[majorTicks, minorTicks]
|
|
7526
|
+
);
|
|
7527
|
+
const showArrowRight = lineStyle === "arrow-right" || lineStyle === "arrow-both";
|
|
7528
|
+
const showArrowLeft = lineStyle === "arrow-left" || lineStyle === "arrow-both";
|
|
6856
7529
|
const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
|
|
6857
7530
|
const inputTickSet = useMemo(() => {
|
|
6858
7531
|
if (isDragMode) return /* @__PURE__ */ new Set();
|
|
6859
7532
|
return new Set(
|
|
6860
|
-
positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
|
|
7533
|
+
positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
|
|
6861
7534
|
);
|
|
6862
7535
|
}, [isDragMode, positions]);
|
|
7536
|
+
const visiblePositions = useMemo(
|
|
7537
|
+
() => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
|
|
7538
|
+
[isDragMode, positions]
|
|
7539
|
+
);
|
|
6863
7540
|
return /* @__PURE__ */ jsxs(
|
|
6864
7541
|
"div",
|
|
6865
7542
|
{
|
|
6866
|
-
className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
|
|
7543
|
+
className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
|
|
6867
7544
|
style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
|
|
6868
|
-
role: "
|
|
7545
|
+
role: "group",
|
|
6869
7546
|
"aria-label": `Number line from ${min} to ${max}`,
|
|
6870
7547
|
children: [
|
|
6871
|
-
|
|
7548
|
+
visiblePositions.length > 0 && /* @__PURE__ */ jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsx(
|
|
6872
7549
|
"div",
|
|
6873
7550
|
{
|
|
6874
7551
|
className: "number-line-position-content",
|
|
@@ -6880,6 +7557,7 @@ const NumberLineRenderer = ({
|
|
|
6880
7557
|
widgets,
|
|
6881
7558
|
values,
|
|
6882
7559
|
droppedInputs,
|
|
7560
|
+
validationDetails,
|
|
6883
7561
|
onChange,
|
|
6884
7562
|
onInputFocus
|
|
6885
7563
|
},
|
|
@@ -6900,50 +7578,43 @@ const NumberLineRenderer = ({
|
|
|
6900
7578
|
showArrowRight,
|
|
6901
7579
|
positions,
|
|
6902
7580
|
values,
|
|
6903
|
-
onChange
|
|
7581
|
+
onChange,
|
|
7582
|
+
highlights,
|
|
7583
|
+
snapKey: counterCount
|
|
6904
7584
|
}
|
|
6905
|
-
) : /* @__PURE__ */
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
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
|
-
}) })
|
|
7585
|
+
) : /* @__PURE__ */ jsx(
|
|
7586
|
+
AxisTicks,
|
|
7587
|
+
{
|
|
7588
|
+
min,
|
|
7589
|
+
max,
|
|
7590
|
+
majorTicks,
|
|
7591
|
+
minorValues,
|
|
7592
|
+
showArrowLeft,
|
|
7593
|
+
showArrowRight,
|
|
7594
|
+
highlights,
|
|
7595
|
+
positions
|
|
7596
|
+
}
|
|
7597
|
+
),
|
|
7598
|
+
/* @__PURE__ */ jsx(
|
|
7599
|
+
AxisLabels,
|
|
7600
|
+
{
|
|
7601
|
+
majorTicks,
|
|
7602
|
+
min,
|
|
7603
|
+
max,
|
|
7604
|
+
labelVisibility,
|
|
7605
|
+
inputTickSet
|
|
7606
|
+
}
|
|
7607
|
+
),
|
|
7608
|
+
/* @__PURE__ */ jsx(HighlightLabelsRow, { highlights, min, max }),
|
|
7609
|
+
counter !== void 0 && /* @__PURE__ */ jsx(
|
|
7610
|
+
CounterControls,
|
|
7611
|
+
{
|
|
7612
|
+
counter,
|
|
7613
|
+
count: counterCount,
|
|
7614
|
+
onDecrement: () => setCounterCount((c) => Math.max(counter.min, c - 1)),
|
|
7615
|
+
onIncrement: () => setCounterCount((c) => Math.min(counter.max, c + 1))
|
|
7616
|
+
}
|
|
7617
|
+
)
|
|
6947
7618
|
]
|
|
6948
7619
|
}
|
|
6949
7620
|
);
|
|
@@ -7011,6 +7682,7 @@ const LayoutRenderer = ({
|
|
|
7011
7682
|
widgets,
|
|
7012
7683
|
values,
|
|
7013
7684
|
droppedInputs,
|
|
7685
|
+
validationDetails,
|
|
7014
7686
|
onChange,
|
|
7015
7687
|
onInputFocus
|
|
7016
7688
|
}) => {
|
|
@@ -7063,13 +7735,18 @@ const LayoutRenderer = ({
|
|
|
7063
7735
|
case JSON_TYPES.WIDGET_REF: {
|
|
7064
7736
|
const widget = widgets[item.widgetId];
|
|
7065
7737
|
if (!widget) return null;
|
|
7738
|
+
const isCorrect = validationDetails?.[item.widgetId];
|
|
7739
|
+
const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
|
|
7066
7740
|
return /* @__PURE__ */ jsx(
|
|
7067
7741
|
WidgetRenderer,
|
|
7068
7742
|
{
|
|
7069
7743
|
widget,
|
|
7070
7744
|
widgetId: item.widgetId,
|
|
7071
7745
|
value: values[item.widgetId] || "",
|
|
7746
|
+
values,
|
|
7072
7747
|
isDropped: droppedInputs.has(item.widgetId),
|
|
7748
|
+
validationStatus,
|
|
7749
|
+
droppedInputs,
|
|
7073
7750
|
onChange,
|
|
7074
7751
|
onInputFocus
|
|
7075
7752
|
}
|
|
@@ -7089,6 +7766,7 @@ const LayoutRenderer = ({
|
|
|
7089
7766
|
widgets,
|
|
7090
7767
|
values,
|
|
7091
7768
|
droppedInputs,
|
|
7769
|
+
validationDetails,
|
|
7092
7770
|
onChange,
|
|
7093
7771
|
onInputFocus
|
|
7094
7772
|
},
|
|
@@ -7107,6 +7785,7 @@ const LayoutRenderer = ({
|
|
|
7107
7785
|
widgets,
|
|
7108
7786
|
values,
|
|
7109
7787
|
droppedInputs,
|
|
7788
|
+
validationDetails,
|
|
7110
7789
|
onChange,
|
|
7111
7790
|
onInputFocus
|
|
7112
7791
|
},
|
|
@@ -7123,6 +7802,7 @@ const LayoutRenderer = ({
|
|
|
7123
7802
|
widgets,
|
|
7124
7803
|
values,
|
|
7125
7804
|
droppedInputs,
|
|
7805
|
+
validationDetails,
|
|
7126
7806
|
onChange,
|
|
7127
7807
|
onInputFocus
|
|
7128
7808
|
}
|
|
@@ -7150,9 +7830,15 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7150
7830
|
const [values, setValues] = useState({});
|
|
7151
7831
|
const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
|
|
7152
7832
|
const [focusedInputId, setFocusedInputId] = useState(null);
|
|
7833
|
+
const [validationDetails, setValidationDetails] = useState(null);
|
|
7153
7834
|
useImperativeHandle(ref, () => ({
|
|
7154
7835
|
submit: () => {
|
|
7155
7836
|
const result = validate(json, values);
|
|
7837
|
+
const details = {};
|
|
7838
|
+
for (const [id, v] of Object.entries(result.details)) {
|
|
7839
|
+
details[id] = v.isCorrect;
|
|
7840
|
+
}
|
|
7841
|
+
setValidationDetails(details);
|
|
7156
7842
|
onSubmit?.(values);
|
|
7157
7843
|
return result;
|
|
7158
7844
|
},
|
|
@@ -7161,6 +7847,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7161
7847
|
setValues({});
|
|
7162
7848
|
setDroppedSources(/* @__PURE__ */ new Map());
|
|
7163
7849
|
setFocusedInputId(null);
|
|
7850
|
+
setValidationDetails(null);
|
|
7164
7851
|
},
|
|
7165
7852
|
focusFirstInput: () => {
|
|
7166
7853
|
const container = document.querySelector(".math-exercise-renderer");
|
|
@@ -7171,8 +7858,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7171
7858
|
}
|
|
7172
7859
|
}), [json, values, onSubmit]);
|
|
7173
7860
|
const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
|
|
7174
|
-
|
|
7175
|
-
setValues(newValues);
|
|
7861
|
+
setValues((prev) => ({ ...prev, [widgetId]: value }));
|
|
7176
7862
|
setDroppedSources((prev) => {
|
|
7177
7863
|
const next = new Map(prev);
|
|
7178
7864
|
if (fromDrop && value !== "" && sourceOptionId) {
|
|
@@ -7182,7 +7868,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7182
7868
|
}
|
|
7183
7869
|
return next;
|
|
7184
7870
|
});
|
|
7185
|
-
onChange?.(
|
|
7871
|
+
onChange?.({ ...values, [widgetId]: value });
|
|
7186
7872
|
};
|
|
7187
7873
|
const handleSubmit = () => {
|
|
7188
7874
|
onSubmit?.(values);
|
|
@@ -7275,6 +7961,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7275
7961
|
widgets: json.widgets,
|
|
7276
7962
|
values,
|
|
7277
7963
|
droppedInputs,
|
|
7964
|
+
validationDetails,
|
|
7278
7965
|
onChange: handleChange,
|
|
7279
7966
|
onInputFocus: setFocusedInputId
|
|
7280
7967
|
},
|
|
@@ -7289,6 +7976,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7289
7976
|
widgets: json.widgets,
|
|
7290
7977
|
values,
|
|
7291
7978
|
droppedInputs,
|
|
7979
|
+
validationDetails,
|
|
7292
7980
|
onChange: handleChange,
|
|
7293
7981
|
onInputFocus: setFocusedInputId
|
|
7294
7982
|
},
|
|
@@ -7304,6 +7992,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7304
7992
|
widgets: json.widgets,
|
|
7305
7993
|
values,
|
|
7306
7994
|
droppedInputs,
|
|
7995
|
+
validationDetails,
|
|
7307
7996
|
onChange: handleChange,
|
|
7308
7997
|
onInputFocus: setFocusedInputId
|
|
7309
7998
|
},
|
|
@@ -7393,6 +8082,8 @@ export {
|
|
|
7393
8082
|
ExerciseMetaSchema,
|
|
7394
8083
|
ExerciseRenderer,
|
|
7395
8084
|
ExpressionValidationSchema,
|
|
8085
|
+
FractionInlineSchema,
|
|
8086
|
+
FractionJSONSchema,
|
|
7396
8087
|
FreeTextInlineSchema,
|
|
7397
8088
|
GroupItemSchema,
|
|
7398
8089
|
HighlightedTextInlineSchema,
|
|
@@ -7406,6 +8097,10 @@ export {
|
|
|
7406
8097
|
LexerError,
|
|
7407
8098
|
LineBlockSchema,
|
|
7408
8099
|
LinebreakSchema,
|
|
8100
|
+
NumberLineCounterItemSchema,
|
|
8101
|
+
NumberLineHighlightItemSchema,
|
|
8102
|
+
NumberLineItemSchema,
|
|
8103
|
+
NumberLinePositionItemSchema,
|
|
7409
8104
|
NumericKeypadJSONSchema,
|
|
7410
8105
|
NumericKeypadToolSchema,
|
|
7411
8106
|
OperatorItemSchema,
|
|
@@ -7436,6 +8131,7 @@ export {
|
|
|
7436
8131
|
createDragOptions,
|
|
7437
8132
|
createExerciseDocument,
|
|
7438
8133
|
createExpressionValidation,
|
|
8134
|
+
createFraction,
|
|
7439
8135
|
createFreeText,
|
|
7440
8136
|
createHighlightedText,
|
|
7441
8137
|
createLineBlock,
|
|
@@ -7448,6 +8144,7 @@ export {
|
|
|
7448
8144
|
evaluate,
|
|
7449
8145
|
extractVariables as extractExpressionVariables,
|
|
7450
8146
|
findValidCombination,
|
|
8147
|
+
getFractionKey,
|
|
7451
8148
|
getMissingInputs,
|
|
7452
8149
|
isBoldText,
|
|
7453
8150
|
isColorText,
|
|
@@ -7455,6 +8152,9 @@ export {
|
|
|
7455
8152
|
isDragOptionsJSON,
|
|
7456
8153
|
isExerciseDocument,
|
|
7457
8154
|
isExpressionValidation,
|
|
8155
|
+
isFraction,
|
|
8156
|
+
isFractionJSON,
|
|
8157
|
+
isFractionKey,
|
|
7458
8158
|
isFreeText,
|
|
7459
8159
|
isGroupItem,
|
|
7460
8160
|
isHighlightedText,
|
|
@@ -7479,8 +8179,9 @@ export {
|
|
|
7479
8179
|
isToolNode,
|
|
7480
8180
|
isValidExpression,
|
|
7481
8181
|
isValidationNode,
|
|
7482
|
-
isWidgetRef
|
|
8182
|
+
isWidgetRef,
|
|
7483
8183
|
parse,
|
|
8184
|
+
parseFractionKey,
|
|
7484
8185
|
parseSafe,
|
|
7485
8186
|
renderExercise,
|
|
7486
8187
|
toAST,
|