@mathscan/math-exercise-engine 1.0.15 → 1.0.16
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 +2803 -81
- package/dist/index.js +972 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +972 -78
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +236 -63
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -52,6 +52,8 @@ const AST_TYPES = {
|
|
|
52
52
|
MATCHER_ROW: "MatcherRow",
|
|
53
53
|
/** Item within a matcher row */
|
|
54
54
|
MATCHER_ITEM: "MatcherItem",
|
|
55
|
+
/** Visual fraction inline widget */
|
|
56
|
+
VISUAL_FRACTION: "VisualFraction",
|
|
55
57
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
56
58
|
// Tools
|
|
57
59
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -132,7 +134,9 @@ const JSON_TYPES = {
|
|
|
132
134
|
/** Numeric keypad widget */
|
|
133
135
|
NUMERIC_KEYPAD: "numeric-keypad",
|
|
134
136
|
/** Inline fraction widget */
|
|
135
|
-
FRACTION: "fraction"
|
|
137
|
+
FRACTION: "fraction",
|
|
138
|
+
/** Visual fraction widget */
|
|
139
|
+
VISUAL_FRACTION: "visual-fraction"
|
|
136
140
|
};
|
|
137
141
|
const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
|
|
138
142
|
const VALIDATION_MODES = {
|
|
@@ -266,6 +270,10 @@ const HIGHLIGHT = createToken({
|
|
|
266
270
|
name: "HIGHLIGHT",
|
|
267
271
|
pattern: /HIGHLIGHT(?!_TEXT)/
|
|
268
272
|
});
|
|
273
|
+
const VISUAL_FRACTION = createToken({
|
|
274
|
+
name: "VISUAL_FRACTION",
|
|
275
|
+
pattern: /VISUAL_FRACTION/
|
|
276
|
+
});
|
|
269
277
|
const FRACTION = createToken({
|
|
270
278
|
name: "FRACTION",
|
|
271
279
|
pattern: /FRACTION/
|
|
@@ -395,6 +403,7 @@ const allTokens = [
|
|
|
395
403
|
CELL,
|
|
396
404
|
COUNTER,
|
|
397
405
|
HIGHLIGHT,
|
|
406
|
+
VISUAL_FRACTION,
|
|
398
407
|
FRACTION,
|
|
399
408
|
MATCHER,
|
|
400
409
|
ITEM,
|
|
@@ -860,6 +869,8 @@ class MathExerciseParser extends CstParser {
|
|
|
860
869
|
{ ALT: () => this.SUBRULE(this.fraction) },
|
|
861
870
|
// IMAGE (inline image element)
|
|
862
871
|
{ ALT: () => this.SUBRULE(this.image) },
|
|
872
|
+
// VISUAL_FRACTION — always allowed; display-only when answer=[] or absence of answer parameter, interactive when answer=[...] with values.
|
|
873
|
+
{ ALT: () => this.SUBRULE(this.visualFraction) },
|
|
863
874
|
// W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
|
|
864
875
|
{ GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
|
|
865
876
|
]);
|
|
@@ -897,6 +908,41 @@ class MathExerciseParser extends CstParser {
|
|
|
897
908
|
this.SUBRULE(this.parameterList);
|
|
898
909
|
this.CONSUME(RBracket);
|
|
899
910
|
});
|
|
911
|
+
this.visualFraction = this.RULE("visualFraction", () => {
|
|
912
|
+
this.CONSUME(VISUAL_FRACTION);
|
|
913
|
+
this.CONSUME(LBracket);
|
|
914
|
+
this.SUBRULE(this.parameterList);
|
|
915
|
+
this.CONSUME(RBracket);
|
|
916
|
+
});
|
|
917
|
+
this.numberArray = this.RULE("numberArray", () => {
|
|
918
|
+
this.CONSUME(LBracket);
|
|
919
|
+
this.MANY(() => {
|
|
920
|
+
this.CONSUME(Space);
|
|
921
|
+
});
|
|
922
|
+
this.OPTION(() => {
|
|
923
|
+
this.CONSUME(NumberLiteral, { LABEL: "items" });
|
|
924
|
+
this.MANY2(() => {
|
|
925
|
+
this.MANY3(() => {
|
|
926
|
+
this.CONSUME2(Space);
|
|
927
|
+
});
|
|
928
|
+
this.CONSUME(Comma);
|
|
929
|
+
this.MANY4(() => {
|
|
930
|
+
this.CONSUME3(Space);
|
|
931
|
+
});
|
|
932
|
+
this.CONSUME2(NumberLiteral, { LABEL: "items" });
|
|
933
|
+
});
|
|
934
|
+
this.OPTION2(() => {
|
|
935
|
+
this.MANY5(() => {
|
|
936
|
+
this.CONSUME4(Space);
|
|
937
|
+
});
|
|
938
|
+
this.CONSUME2(Comma);
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
this.MANY6(() => {
|
|
942
|
+
this.CONSUME5(Space);
|
|
943
|
+
});
|
|
944
|
+
this.CONSUME(RBracket);
|
|
945
|
+
});
|
|
900
946
|
this.inlineInput = this.RULE("inlineInput", () => {
|
|
901
947
|
this.OR([
|
|
902
948
|
{ ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
|
|
@@ -984,7 +1030,9 @@ class MathExerciseParser extends CstParser {
|
|
|
984
1030
|
// Tuple value: (start, end)
|
|
985
1031
|
{ ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
|
|
986
1032
|
// Curly-brace string list: {"a", "b", "c"}
|
|
987
|
-
{ ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) }
|
|
1033
|
+
{ ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
|
|
1034
|
+
// Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
|
|
1035
|
+
{ ALT: () => this.SUBRULE(this.numberArray, { LABEL: "value" }) }
|
|
988
1036
|
]);
|
|
989
1037
|
});
|
|
990
1038
|
this.tuple = this.RULE("tuple", () => {
|
|
@@ -1040,6 +1088,25 @@ class MathExerciseParser extends CstParser {
|
|
|
1040
1088
|
}
|
|
1041
1089
|
}
|
|
1042
1090
|
const parser = new MathExerciseParser();
|
|
1091
|
+
function parseOrThrow(schema, data, label) {
|
|
1092
|
+
const result = schema.safeParse(data);
|
|
1093
|
+
if (!result.success) {
|
|
1094
|
+
const issue = result.error.issues[0];
|
|
1095
|
+
if (!issue) throw new Error(`${label}: validation failed`);
|
|
1096
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
|
1097
|
+
throw new Error(`${label}: ${path} ${issue.message}`);
|
|
1098
|
+
}
|
|
1099
|
+
return result.data;
|
|
1100
|
+
}
|
|
1101
|
+
const VISUAL_FRACTION_SHAPES = ["bar", "circle", "triangle", "grid"];
|
|
1102
|
+
const VISUAL_FRACTION_SHAPE = {
|
|
1103
|
+
BAR: "bar",
|
|
1104
|
+
CIRCLE: "circle",
|
|
1105
|
+
TRIANGLE: "triangle",
|
|
1106
|
+
GRID: "grid"
|
|
1107
|
+
};
|
|
1108
|
+
const VISUAL_FRACTION_DEFAULT_SIZE = 1.35;
|
|
1109
|
+
const VISUAL_FRACTION_MAX_DIVISIONS = 100;
|
|
1043
1110
|
const FreeTextInlineSchema = z.object({
|
|
1044
1111
|
type: z.literal(AST_TYPES.FREE_TEXT),
|
|
1045
1112
|
value: z.string()
|
|
@@ -1069,6 +1136,7 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
|
|
|
1069
1136
|
name: z.string(),
|
|
1070
1137
|
inputType: z.literal(INPUT_TYPES.STRING),
|
|
1071
1138
|
length: z.number().int().positive(),
|
|
1139
|
+
placeholder: z.string().max(32).optional(),
|
|
1072
1140
|
answer: z.union([z.string(), z.number()]).optional()
|
|
1073
1141
|
}),
|
|
1074
1142
|
z.object({
|
|
@@ -1076,6 +1144,7 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
|
|
|
1076
1144
|
name: z.string(),
|
|
1077
1145
|
inputType: z.literal(INPUT_TYPES.NUMERIC),
|
|
1078
1146
|
digits: z.number().int().positive(),
|
|
1147
|
+
placeholder: z.string().max(32).optional(),
|
|
1079
1148
|
answer: z.union([z.string(), z.number()]).optional()
|
|
1080
1149
|
})
|
|
1081
1150
|
]);
|
|
@@ -1098,6 +1167,60 @@ const ImageInlineSchema = z.object({
|
|
|
1098
1167
|
width: z.number().positive().optional(),
|
|
1099
1168
|
height: z.number().positive().optional()
|
|
1100
1169
|
});
|
|
1170
|
+
const VisualFractionInlineSchema = z.object({
|
|
1171
|
+
type: z.literal(AST_TYPES.VISUAL_FRACTION),
|
|
1172
|
+
name: z.string(),
|
|
1173
|
+
shape: z.enum(VISUAL_FRACTION_SHAPES).default("bar"),
|
|
1174
|
+
divisions: z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
|
|
1175
|
+
message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
|
|
1176
|
+
}),
|
|
1177
|
+
filled: z.array(z.number().int().nonnegative()).default([]),
|
|
1178
|
+
answer: z.array(z.number().int().nonnegative()).default([]),
|
|
1179
|
+
orientation: z.enum(["horizontal", "vertical"]).optional(),
|
|
1180
|
+
label: z.string().optional(),
|
|
1181
|
+
/** Unified author size.
|
|
1182
|
+
* - bar / grid: overrides `--visual-fraction-part-size` (cell size).
|
|
1183
|
+
* - circle / triangle: maps to `--visual-fraction-size` with the renderer scale factor.
|
|
1184
|
+
* Omit to use CSS defaults. */
|
|
1185
|
+
size: z.number().positive().optional()
|
|
1186
|
+
}).superRefine(validateVisualFractionCapacity);
|
|
1187
|
+
function validateVisualFractionCapacity(data, ctx) {
|
|
1188
|
+
const filledTotal = data.filled.reduce((sum, count) => sum + count, 0);
|
|
1189
|
+
const answerTotal = data.answer.reduce((sum, count) => sum + count, 0);
|
|
1190
|
+
if (filledTotal > data.divisions) {
|
|
1191
|
+
ctx.addIssue({
|
|
1192
|
+
code: z.ZodIssueCode.custom,
|
|
1193
|
+
message: `filled total (${filledTotal}) cannot exceed divisions (${data.divisions})`,
|
|
1194
|
+
path: ["filled"]
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (filledTotal + answerTotal > data.divisions) {
|
|
1198
|
+
ctx.addIssue({
|
|
1199
|
+
code: z.ZodIssueCode.custom,
|
|
1200
|
+
message: `filled + answer total (${filledTotal + answerTotal}) cannot exceed divisions (${data.divisions})`,
|
|
1201
|
+
path: ["answer"]
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
const shape = data.shape;
|
|
1205
|
+
if (shape === "triangle" && !isPerfectSquare(data.divisions)) {
|
|
1206
|
+
ctx.addIssue({
|
|
1207
|
+
code: z.ZodIssueCode.custom,
|
|
1208
|
+
message: `triangle divisions (${data.divisions}) must be a perfect square to render equal triangle parts`,
|
|
1209
|
+
path: ["divisions"]
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
if (shape === "grid" && !isPerfectSquare(data.divisions)) {
|
|
1213
|
+
ctx.addIssue({
|
|
1214
|
+
code: z.ZodIssueCode.custom,
|
|
1215
|
+
message: `grid divisions (${data.divisions}) must be a perfect square to render equal rows and columns`,
|
|
1216
|
+
path: ["divisions"]
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function isPerfectSquare(value) {
|
|
1221
|
+
const root = Math.sqrt(value);
|
|
1222
|
+
return Number.isInteger(root);
|
|
1223
|
+
}
|
|
1101
1224
|
const InlineNodeSchema = z.union([
|
|
1102
1225
|
FreeTextInlineSchema,
|
|
1103
1226
|
SpaceInlineSchema,
|
|
@@ -1106,7 +1229,8 @@ const InlineNodeSchema = z.union([
|
|
|
1106
1229
|
BoldTextInlineSchema,
|
|
1107
1230
|
InlineInputWidgetSchema,
|
|
1108
1231
|
FractionInlineSchema,
|
|
1109
|
-
ImageInlineSchema
|
|
1232
|
+
ImageInlineSchema,
|
|
1233
|
+
VisualFractionInlineSchema
|
|
1110
1234
|
]);
|
|
1111
1235
|
function createFreeText(value) {
|
|
1112
1236
|
return FreeTextInlineSchema.parse({
|
|
@@ -1141,23 +1265,25 @@ function createBoldText(value, highlight) {
|
|
|
1141
1265
|
highlight
|
|
1142
1266
|
});
|
|
1143
1267
|
}
|
|
1144
|
-
function createStringInput(name, length, answer) {
|
|
1145
|
-
return InlineInputWidgetSchema
|
|
1268
|
+
function createStringInput(name, length, answer, placeholder) {
|
|
1269
|
+
return parseOrThrow(InlineInputWidgetSchema, {
|
|
1146
1270
|
type: AST_TYPES.INLINE_INPUT,
|
|
1147
1271
|
name,
|
|
1148
1272
|
inputType: INPUT_TYPES.STRING,
|
|
1149
1273
|
length,
|
|
1274
|
+
placeholder,
|
|
1150
1275
|
answer
|
|
1151
|
-
});
|
|
1276
|
+
}, "STRING_INPUT");
|
|
1152
1277
|
}
|
|
1153
|
-
function createNumericInput(name, digits, answer) {
|
|
1154
|
-
return InlineInputWidgetSchema
|
|
1278
|
+
function createNumericInput(name, digits, answer, placeholder) {
|
|
1279
|
+
return parseOrThrow(InlineInputWidgetSchema, {
|
|
1155
1280
|
type: AST_TYPES.INLINE_INPUT,
|
|
1156
1281
|
name,
|
|
1157
1282
|
inputType: INPUT_TYPES.NUMERIC,
|
|
1158
1283
|
digits,
|
|
1284
|
+
placeholder,
|
|
1159
1285
|
answer
|
|
1160
|
-
});
|
|
1286
|
+
}, "NUMERIC_INPUT");
|
|
1161
1287
|
}
|
|
1162
1288
|
function createImage(name, src, alt, options = {}) {
|
|
1163
1289
|
return ImageInlineSchema.parse({
|
|
@@ -1175,6 +1301,14 @@ function createFraction(name, options = {}) {
|
|
|
1175
1301
|
...options
|
|
1176
1302
|
});
|
|
1177
1303
|
}
|
|
1304
|
+
function createVisualFraction(name, divisions, options = {}) {
|
|
1305
|
+
return VisualFractionInlineSchema.parse({
|
|
1306
|
+
type: AST_TYPES.VISUAL_FRACTION,
|
|
1307
|
+
name,
|
|
1308
|
+
divisions,
|
|
1309
|
+
...options
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1178
1312
|
function isFreeText(value) {
|
|
1179
1313
|
return FreeTextInlineSchema.safeParse(value).success;
|
|
1180
1314
|
}
|
|
@@ -1193,6 +1327,9 @@ function isImage(value) {
|
|
|
1193
1327
|
function isInlineNode(value) {
|
|
1194
1328
|
return InlineNodeSchema.safeParse(value).success;
|
|
1195
1329
|
}
|
|
1330
|
+
function isVisualFractionInline(value) {
|
|
1331
|
+
return VisualFractionInlineSchema.safeParse(value).success;
|
|
1332
|
+
}
|
|
1196
1333
|
const TextBlockSchema = z.object({
|
|
1197
1334
|
type: z.literal(AST_TYPES.TEXT_BLOCK),
|
|
1198
1335
|
value: z.string()
|
|
@@ -1388,6 +1525,10 @@ const DisplayFractionInlineSchema = FractionInlineSchema.refine(
|
|
|
1388
1525
|
(f) => f.numerator !== void 0 && f.denominator !== void 0,
|
|
1389
1526
|
{ message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
|
|
1390
1527
|
);
|
|
1528
|
+
const DisplayVisualFractionInlineSchema = VisualFractionInlineSchema.refine(
|
|
1529
|
+
(vf) => vf.answer.length === 0,
|
|
1530
|
+
{ message: "VISUAL_FRACTION inside MATCHER ITEM must be display-only (answer must be empty: answer=[])" }
|
|
1531
|
+
);
|
|
1391
1532
|
const DisplayInlineNodeSchema = z.union([
|
|
1392
1533
|
FreeTextInlineSchema,
|
|
1393
1534
|
SpaceInlineSchema,
|
|
@@ -1395,7 +1536,8 @@ const DisplayInlineNodeSchema = z.union([
|
|
|
1395
1536
|
ColorTextInlineSchema,
|
|
1396
1537
|
BoldTextInlineSchema,
|
|
1397
1538
|
DisplayFractionInlineSchema,
|
|
1398
|
-
ImageInlineSchema
|
|
1539
|
+
ImageInlineSchema,
|
|
1540
|
+
DisplayVisualFractionInlineSchema
|
|
1399
1541
|
]);
|
|
1400
1542
|
const MatcherItemSchema = z.object({
|
|
1401
1543
|
type: z.literal(AST_TYPES.MATCHER_ITEM),
|
|
@@ -1491,7 +1633,8 @@ const DragOptionsToolSchema = z.object({
|
|
|
1491
1633
|
const NumericKeypadToolSchema = z.object({
|
|
1492
1634
|
type: z.literal(AST_TYPES.NUMERIC_KEYPAD),
|
|
1493
1635
|
includeOperators: z.boolean().optional(),
|
|
1494
|
-
includeDecimal: z.boolean().optional()
|
|
1636
|
+
includeDecimal: z.boolean().optional(),
|
|
1637
|
+
style: z.enum(["diamond", "classic"]).optional()
|
|
1495
1638
|
});
|
|
1496
1639
|
const ToolNodeSchema = z.discriminatedUnion("type", [
|
|
1497
1640
|
DragOptionsToolSchema,
|
|
@@ -1503,11 +1646,12 @@ function createDragOptions(options) {
|
|
|
1503
1646
|
options
|
|
1504
1647
|
});
|
|
1505
1648
|
}
|
|
1506
|
-
function createNumericKeypad(includeOperators, includeDecimal) {
|
|
1649
|
+
function createNumericKeypad(includeOperators, includeDecimal, style) {
|
|
1507
1650
|
return NumericKeypadToolSchema.parse({
|
|
1508
1651
|
type: AST_TYPES.NUMERIC_KEYPAD,
|
|
1509
1652
|
includeOperators,
|
|
1510
|
-
includeDecimal
|
|
1653
|
+
includeDecimal,
|
|
1654
|
+
style
|
|
1511
1655
|
});
|
|
1512
1656
|
}
|
|
1513
1657
|
function isDragOptions(value) {
|
|
@@ -1861,6 +2005,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1861
2005
|
if (ctx.image) {
|
|
1862
2006
|
return this.visit(ctx.image);
|
|
1863
2007
|
}
|
|
2008
|
+
if (ctx.visualFraction) {
|
|
2009
|
+
return this.visit(ctx.visualFraction);
|
|
2010
|
+
}
|
|
1864
2011
|
if (ctx.inlineInput) {
|
|
1865
2012
|
return this.visit(ctx.inlineInput);
|
|
1866
2013
|
}
|
|
@@ -1912,16 +2059,32 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1912
2059
|
const height = params.height;
|
|
1913
2060
|
return createImage(name, src, alt, { width, height });
|
|
1914
2061
|
}
|
|
2062
|
+
visualFraction(ctx) {
|
|
2063
|
+
const params = this.visit(ctx.parameterList);
|
|
2064
|
+
const name = params.name;
|
|
2065
|
+
const divisions = params.divisions;
|
|
2066
|
+
const shape = params.shape;
|
|
2067
|
+
const filled = params.filled ?? [];
|
|
2068
|
+
const answer = params.answer ?? [];
|
|
2069
|
+
const orientation = params.orientation;
|
|
2070
|
+
const label = params.label !== void 0 ? params.label : void 0;
|
|
2071
|
+
const size = params.size !== void 0 ? params.size : void 0;
|
|
2072
|
+
return createVisualFraction(name, divisions, { shape, filled, answer, orientation, label, size });
|
|
2073
|
+
}
|
|
2074
|
+
numberArray(ctx) {
|
|
2075
|
+
return ctx.items ? ctx.items.map((t) => parseFloat(t.image)) : [];
|
|
2076
|
+
}
|
|
1915
2077
|
inlineInput(ctx) {
|
|
1916
2078
|
const params = this.visit(ctx.parameterList);
|
|
1917
2079
|
const name = params.name;
|
|
1918
2080
|
const answer = params.answer;
|
|
2081
|
+
const placeholder = params.placeholder;
|
|
1919
2082
|
if (ctx.stringInput) {
|
|
1920
2083
|
const length = params.length;
|
|
1921
|
-
return createStringInput(name, length, answer);
|
|
2084
|
+
return createStringInput(name, length, answer, placeholder);
|
|
1922
2085
|
}
|
|
1923
2086
|
const digits = params.digits;
|
|
1924
|
-
return createNumericInput(name, digits, answer);
|
|
2087
|
+
return createNumericInput(name, digits, answer, placeholder);
|
|
1925
2088
|
}
|
|
1926
2089
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1927
2090
|
// Tools
|
|
@@ -1943,7 +2106,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1943
2106
|
const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
|
|
1944
2107
|
const includeOperators = params.includeOperators === true || params.includeOperators === "true";
|
|
1945
2108
|
const includeDecimal = params.includeDecimal === true || params.includeDecimal === "true";
|
|
1946
|
-
|
|
2109
|
+
const style = params.style;
|
|
2110
|
+
return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0, style);
|
|
1947
2111
|
}
|
|
1948
2112
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1949
2113
|
// Validation
|
|
@@ -2186,12 +2350,14 @@ const MatcherRowItemSchema = z.object({
|
|
|
2186
2350
|
const StringInputJSONSchema = z.object({
|
|
2187
2351
|
type: z.literal(JSON_TYPES.INLINE_INPUT),
|
|
2188
2352
|
inputType: z.literal(INPUT_TYPES.STRING),
|
|
2189
|
-
length: z.number().int().positive()
|
|
2353
|
+
length: z.number().int().positive(),
|
|
2354
|
+
placeholder: z.string().max(32).optional()
|
|
2190
2355
|
});
|
|
2191
2356
|
const NumericInputJSONSchema = z.object({
|
|
2192
2357
|
type: z.literal(JSON_TYPES.INLINE_INPUT),
|
|
2193
2358
|
inputType: z.literal(INPUT_TYPES.NUMERIC),
|
|
2194
|
-
digits: z.number().int().positive()
|
|
2359
|
+
digits: z.number().int().positive(),
|
|
2360
|
+
placeholder: z.string().max(32).optional()
|
|
2195
2361
|
});
|
|
2196
2362
|
const InlineInputJSONSchema = z.discriminatedUnion("inputType", [
|
|
2197
2363
|
StringInputJSONSchema,
|
|
@@ -2208,7 +2374,8 @@ const RadioOptionsJSONSchema = z.object({
|
|
|
2208
2374
|
const NumericKeypadJSONSchema = z.object({
|
|
2209
2375
|
type: z.literal(JSON_TYPES.NUMERIC_KEYPAD),
|
|
2210
2376
|
includeOperators: z.boolean().optional(),
|
|
2211
|
-
includeDecimal: z.boolean().optional()
|
|
2377
|
+
includeDecimal: z.boolean().optional(),
|
|
2378
|
+
style: z.enum(["diamond", "classic"]).optional()
|
|
2212
2379
|
});
|
|
2213
2380
|
const FractionJSONSchema = z.object({
|
|
2214
2381
|
type: z.literal(JSON_TYPES.FRACTION),
|
|
@@ -2224,24 +2391,44 @@ const MatcherWidgetJSONSchema = z.object({
|
|
|
2224
2391
|
/** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
|
|
2225
2392
|
groupCount: z.number().int().min(1)
|
|
2226
2393
|
});
|
|
2394
|
+
const VisualFractionJSONSchema = z.object({
|
|
2395
|
+
type: z.literal(JSON_TYPES.VISUAL_FRACTION),
|
|
2396
|
+
shape: z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
|
|
2397
|
+
divisions: z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
|
|
2398
|
+
message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
|
|
2399
|
+
}),
|
|
2400
|
+
filled: z.array(z.number().int().nonnegative()),
|
|
2401
|
+
answer: z.array(z.number().int().nonnegative()),
|
|
2402
|
+
orientation: z.enum(["horizontal", "vertical"]).optional(),
|
|
2403
|
+
/** Optional label text rendered near the shape — replaces the old showLabel boolean */
|
|
2404
|
+
label: z.string().optional(),
|
|
2405
|
+
/** Overall shape size in unified author units.
|
|
2406
|
+
* - bar / grid: overrides `--visual-fraction-part-size` CSS custom property (cell size in rem).
|
|
2407
|
+
* - circle / triangle: overrides `--visual-fraction-size` through the renderer scale factor.
|
|
2408
|
+
*/
|
|
2409
|
+
size: z.number().positive().optional()
|
|
2410
|
+
}).superRefine(validateVisualFractionCapacity);
|
|
2227
2411
|
const WidgetJSONSchema = z.union([
|
|
2228
2412
|
InlineInputJSONSchema,
|
|
2229
2413
|
DragOptionsJSONSchema,
|
|
2230
2414
|
RadioOptionsJSONSchema,
|
|
2231
2415
|
NumericKeypadJSONSchema,
|
|
2232
2416
|
FractionJSONSchema,
|
|
2233
|
-
MatcherWidgetJSONSchema
|
|
2417
|
+
MatcherWidgetJSONSchema,
|
|
2418
|
+
VisualFractionJSONSchema
|
|
2234
2419
|
]);
|
|
2235
2420
|
const ValidationJSONSchema = z.object({
|
|
2236
2421
|
mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
|
|
2237
2422
|
// Simple answers: string|number per widget id.
|
|
2238
2423
|
// Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
|
|
2239
2424
|
// mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
|
|
2425
|
+
// VisualFraction answers: number[] — one count per color group.
|
|
2240
2426
|
answers: z.record(
|
|
2241
2427
|
z.union([
|
|
2242
2428
|
z.string(),
|
|
2243
2429
|
z.number(),
|
|
2244
|
-
z.array(z.array(z.string().min(1)).min(1)).min(1)
|
|
2430
|
+
z.array(z.array(z.string().min(1)).min(1)).min(1),
|
|
2431
|
+
z.array(z.number().int().nonnegative())
|
|
2245
2432
|
])
|
|
2246
2433
|
).optional(),
|
|
2247
2434
|
expression: z.string().optional(),
|
|
@@ -2392,13 +2579,15 @@ function toJSON(ast) {
|
|
|
2392
2579
|
widgets[inline.name] = {
|
|
2393
2580
|
type: JSON_TYPES.INLINE_INPUT,
|
|
2394
2581
|
inputType: INPUT_TYPES.STRING,
|
|
2395
|
-
length: inline.length
|
|
2582
|
+
length: inline.length,
|
|
2583
|
+
placeholder: inline.placeholder
|
|
2396
2584
|
};
|
|
2397
2585
|
} else {
|
|
2398
2586
|
widgets[inline.name] = {
|
|
2399
2587
|
type: JSON_TYPES.INLINE_INPUT,
|
|
2400
2588
|
inputType: INPUT_TYPES.NUMERIC,
|
|
2401
|
-
digits: inline.digits
|
|
2589
|
+
digits: inline.digits,
|
|
2590
|
+
placeholder: inline.placeholder
|
|
2402
2591
|
};
|
|
2403
2592
|
}
|
|
2404
2593
|
if (inline.answer !== void 0) {
|
|
@@ -2416,6 +2605,22 @@ function toJSON(ast) {
|
|
|
2416
2605
|
answers[inline.name] = inline.answer;
|
|
2417
2606
|
}
|
|
2418
2607
|
}
|
|
2608
|
+
if (inline.type === AST_TYPES.VISUAL_FRACTION) {
|
|
2609
|
+
const answerArr = inline.answer ?? [];
|
|
2610
|
+
widgets[inline.name] = {
|
|
2611
|
+
type: JSON_TYPES.VISUAL_FRACTION,
|
|
2612
|
+
shape: inline.shape,
|
|
2613
|
+
divisions: inline.divisions,
|
|
2614
|
+
filled: inline.filled ?? [],
|
|
2615
|
+
answer: answerArr,
|
|
2616
|
+
...inline.orientation !== void 0 && { orientation: inline.orientation },
|
|
2617
|
+
...inline.label !== void 0 && { label: inline.label },
|
|
2618
|
+
...inline.size !== void 0 && { size: inline.size }
|
|
2619
|
+
};
|
|
2620
|
+
if (answerArr.length > 0) {
|
|
2621
|
+
answers[inline.name] = answerArr;
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2419
2624
|
}
|
|
2420
2625
|
for (const block of ast.lines) {
|
|
2421
2626
|
if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
@@ -2442,7 +2647,8 @@ function toJSON(ast) {
|
|
|
2442
2647
|
widgets["_numeric_keypad"] = {
|
|
2443
2648
|
type: JSON_TYPES.NUMERIC_KEYPAD,
|
|
2444
2649
|
includeOperators: tool.includeOperators,
|
|
2445
|
-
includeDecimal: tool.includeDecimal
|
|
2650
|
+
includeDecimal: tool.includeDecimal,
|
|
2651
|
+
style: tool.style
|
|
2446
2652
|
};
|
|
2447
2653
|
}
|
|
2448
2654
|
}
|
|
@@ -2656,6 +2862,12 @@ function transformInline(inline, idGenerator) {
|
|
|
2656
2862
|
...inline.width !== void 0 && { width: inline.width },
|
|
2657
2863
|
...inline.height !== void 0 && { height: inline.height }
|
|
2658
2864
|
};
|
|
2865
|
+
case AST_TYPES.VISUAL_FRACTION:
|
|
2866
|
+
return {
|
|
2867
|
+
id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
|
|
2868
|
+
type: JSON_TYPES.WIDGET_REF,
|
|
2869
|
+
widgetId: inline.name
|
|
2870
|
+
};
|
|
2659
2871
|
default:
|
|
2660
2872
|
throw new Error(`Unknown inline type: ${inline.type}`);
|
|
2661
2873
|
}
|
|
@@ -8198,6 +8410,11 @@ function areMatcherEqual(expected, result) {
|
|
|
8198
8410
|
const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
|
|
8199
8411
|
return lodashExports.isEqual(normalize2(expected), normalize2(result));
|
|
8200
8412
|
}
|
|
8413
|
+
function areVisualFractionCountsEqual(expected, result) {
|
|
8414
|
+
if (!Array.isArray(result)) return false;
|
|
8415
|
+
if (expected.length !== result.length) return false;
|
|
8416
|
+
return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
|
|
8417
|
+
}
|
|
8201
8418
|
const math$1 = create(all);
|
|
8202
8419
|
function collectNumberLines(items) {
|
|
8203
8420
|
const result = [];
|
|
@@ -8246,6 +8463,15 @@ function validateSimple(exercise, userInputs) {
|
|
|
8246
8463
|
else allCorrect = false;
|
|
8247
8464
|
continue;
|
|
8248
8465
|
}
|
|
8466
|
+
if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
|
|
8467
|
+
const userValue2 = userInputs[widgetId];
|
|
8468
|
+
const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
|
|
8469
|
+
details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
|
|
8470
|
+
totalCount++;
|
|
8471
|
+
if (isCorrect2) correctCount++;
|
|
8472
|
+
else allCorrect = false;
|
|
8473
|
+
continue;
|
|
8474
|
+
}
|
|
8249
8475
|
totalCount++;
|
|
8250
8476
|
const userValue = userInputs[widgetId];
|
|
8251
8477
|
const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
|
|
@@ -8752,10 +8978,10 @@ function getOwnerDocument(target) {
|
|
|
8752
8978
|
}
|
|
8753
8979
|
return document;
|
|
8754
8980
|
}
|
|
8755
|
-
const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect;
|
|
8981
|
+
const useIsomorphicLayoutEffect$1 = canUseDOM ? useLayoutEffect : useEffect;
|
|
8756
8982
|
function useEvent(handler) {
|
|
8757
8983
|
const handlerRef = useRef(handler);
|
|
8758
|
-
useIsomorphicLayoutEffect(() => {
|
|
8984
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
8759
8985
|
handlerRef.current = handler;
|
|
8760
8986
|
});
|
|
8761
8987
|
return useCallback(function() {
|
|
@@ -8783,7 +9009,7 @@ function useLatestValue(value, dependencies) {
|
|
|
8783
9009
|
dependencies = [value];
|
|
8784
9010
|
}
|
|
8785
9011
|
const valueRef = useRef(value);
|
|
8786
|
-
useIsomorphicLayoutEffect(() => {
|
|
9012
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
8787
9013
|
if (valueRef.current !== value) {
|
|
8788
9014
|
valueRef.current = value;
|
|
8789
9015
|
}
|
|
@@ -10651,9 +10877,9 @@ function useResizeObserver(_ref) {
|
|
|
10651
10877
|
return void 0;
|
|
10652
10878
|
}
|
|
10653
10879
|
const {
|
|
10654
|
-
ResizeObserver
|
|
10880
|
+
ResizeObserver: ResizeObserver2
|
|
10655
10881
|
} = window;
|
|
10656
|
-
return new
|
|
10882
|
+
return new ResizeObserver2(handleResize);
|
|
10657
10883
|
},
|
|
10658
10884
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
10659
10885
|
[disabled]
|
|
@@ -10707,7 +10933,7 @@ function useRect(element, measure, fallbackRect) {
|
|
|
10707
10933
|
const resizeObserver = useResizeObserver({
|
|
10708
10934
|
callback: measureRect
|
|
10709
10935
|
});
|
|
10710
|
-
useIsomorphicLayoutEffect(() => {
|
|
10936
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
10711
10937
|
measureRect();
|
|
10712
10938
|
if (element) {
|
|
10713
10939
|
resizeObserver == null ? void 0 : resizeObserver.observe(element);
|
|
@@ -10881,7 +11107,7 @@ function useRects(elements, measure) {
|
|
|
10881
11107
|
const resizeObserver = useResizeObserver({
|
|
10882
11108
|
callback: measureRects
|
|
10883
11109
|
});
|
|
10884
|
-
useIsomorphicLayoutEffect(() => {
|
|
11110
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
10885
11111
|
resizeObserver == null ? void 0 : resizeObserver.disconnect();
|
|
10886
11112
|
measureRects();
|
|
10887
11113
|
elements.forEach((element) => resizeObserver == null ? void 0 : resizeObserver.observe(element));
|
|
@@ -11243,7 +11469,7 @@ function useLayoutShiftScrollCompensation(_ref) {
|
|
|
11243
11469
|
x: config,
|
|
11244
11470
|
y: config
|
|
11245
11471
|
} : config;
|
|
11246
|
-
useIsomorphicLayoutEffect(() => {
|
|
11472
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11247
11473
|
const disabled = !x && !y;
|
|
11248
11474
|
if (disabled || !activeNode) {
|
|
11249
11475
|
initialized.current = false;
|
|
@@ -11604,7 +11830,7 @@ const DndContext = /* @__PURE__ */ memo(function DndContext2(_ref) {
|
|
|
11604
11830
|
}, [draggableNodes, instantiateSensor]);
|
|
11605
11831
|
const activators = useCombineActivators(sensors, bindActivatorToSensorInstantiator);
|
|
11606
11832
|
useSensorSetup(sensors);
|
|
11607
|
-
useIsomorphicLayoutEffect(() => {
|
|
11833
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11608
11834
|
if (activeNodeRect && status === Status.Initializing) {
|
|
11609
11835
|
setStatus(Status.Initialized);
|
|
11610
11836
|
}
|
|
@@ -11688,7 +11914,7 @@ const DndContext = /* @__PURE__ */ memo(function DndContext2(_ref) {
|
|
|
11688
11914
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
11689
11915
|
[overId]
|
|
11690
11916
|
);
|
|
11691
|
-
useIsomorphicLayoutEffect(() => {
|
|
11917
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11692
11918
|
sensorContext.current = {
|
|
11693
11919
|
activatorEvent,
|
|
11694
11920
|
active,
|
|
@@ -11815,7 +12041,7 @@ function useDraggable(_ref) {
|
|
|
11815
12041
|
const [activatorNode, setActivatorNodeRef] = useNodeRef();
|
|
11816
12042
|
const listeners = useSyntheticListeners(activators, id);
|
|
11817
12043
|
const dataRef = useLatestValue(data);
|
|
11818
|
-
useIsomorphicLayoutEffect(
|
|
12044
|
+
useIsomorphicLayoutEffect$1(
|
|
11819
12045
|
() => {
|
|
11820
12046
|
draggableNodes.set(id, {
|
|
11821
12047
|
id,
|
|
@@ -11988,7 +12214,7 @@ function AnimationManager(_ref) {
|
|
|
11988
12214
|
if (!children && !clonedChildren && previousChildren) {
|
|
11989
12215
|
setClonedChildren(previousChildren);
|
|
11990
12216
|
}
|
|
11991
|
-
useIsomorphicLayoutEffect(() => {
|
|
12217
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11992
12218
|
if (!element) {
|
|
11993
12219
|
return;
|
|
11994
12220
|
}
|
|
@@ -12670,6 +12896,7 @@ const InlineInput = ({
|
|
|
12670
12896
|
inputType,
|
|
12671
12897
|
length,
|
|
12672
12898
|
digits,
|
|
12899
|
+
placeholder,
|
|
12673
12900
|
onChange,
|
|
12674
12901
|
onFocus
|
|
12675
12902
|
}) => {
|
|
@@ -12715,19 +12942,88 @@ const InlineInput = ({
|
|
|
12715
12942
|
onChange: (e) => handleChange(e.target.value, false),
|
|
12716
12943
|
onFocus: () => onFocus?.(widgetId),
|
|
12717
12944
|
maxLength: expectedLength,
|
|
12718
|
-
style: { width: dynamicWidth }
|
|
12945
|
+
style: { width: dynamicWidth },
|
|
12946
|
+
placeholder
|
|
12719
12947
|
}
|
|
12720
12948
|
)
|
|
12721
12949
|
)
|
|
12722
12950
|
}
|
|
12723
12951
|
);
|
|
12724
12952
|
};
|
|
12953
|
+
const PillBackground = ({
|
|
12954
|
+
width = "100%",
|
|
12955
|
+
minHeight = "72px",
|
|
12956
|
+
fill = "#FFE7B4",
|
|
12957
|
+
children,
|
|
12958
|
+
className = "",
|
|
12959
|
+
style,
|
|
12960
|
+
...rest
|
|
12961
|
+
}) => {
|
|
12962
|
+
return /* @__PURE__ */ jsxs(
|
|
12963
|
+
"div",
|
|
12964
|
+
{
|
|
12965
|
+
className: `pill-bg-wrapper ${className}`.trim(),
|
|
12966
|
+
style: {
|
|
12967
|
+
position: "relative",
|
|
12968
|
+
display: "inline-flex",
|
|
12969
|
+
alignItems: "center",
|
|
12970
|
+
justifyContent: "center",
|
|
12971
|
+
width,
|
|
12972
|
+
minHeight,
|
|
12973
|
+
...style
|
|
12974
|
+
},
|
|
12975
|
+
...rest,
|
|
12976
|
+
children: [
|
|
12977
|
+
/* @__PURE__ */ jsx(
|
|
12978
|
+
"svg",
|
|
12979
|
+
{
|
|
12980
|
+
viewBox: "0 0 510 108",
|
|
12981
|
+
preserveAspectRatio: "none",
|
|
12982
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
12983
|
+
"aria-hidden": "true",
|
|
12984
|
+
style: {
|
|
12985
|
+
position: "absolute",
|
|
12986
|
+
inset: 0,
|
|
12987
|
+
width: "100%",
|
|
12988
|
+
height: "100%",
|
|
12989
|
+
display: "block"
|
|
12990
|
+
},
|
|
12991
|
+
children: /* @__PURE__ */ jsx(
|
|
12992
|
+
"path",
|
|
12993
|
+
{
|
|
12994
|
+
d: "M439.707 0C457.071 0 472.52 8.196 482.398 20.9297C498.608 26.3308 510 39.1028 510 54C510 68.8966 498.608 81.667 482.399 87.0684C472.521 99.803 457.072 108 439.707 108H70.8975C53.653 108 38.2956 99.9172 28.4092 87.333C11.7719 82.0918 0.00025108 69.1432 0 54C0 38.8562 11.7721 25.906 28.4102 20.665C38.2966 8.08186 53.6537 0 70.8975 0H439.707Z",
|
|
12995
|
+
fill
|
|
12996
|
+
}
|
|
12997
|
+
)
|
|
12998
|
+
}
|
|
12999
|
+
),
|
|
13000
|
+
/* @__PURE__ */ jsx(
|
|
13001
|
+
"div",
|
|
13002
|
+
{
|
|
13003
|
+
style: {
|
|
13004
|
+
position: "relative",
|
|
13005
|
+
zIndex: 1,
|
|
13006
|
+
display: "flex",
|
|
13007
|
+
alignItems: "center",
|
|
13008
|
+
justifyContent: "center",
|
|
13009
|
+
width: "100%",
|
|
13010
|
+
height: "100%",
|
|
13011
|
+
padding: "0 2rem",
|
|
13012
|
+
boxSizing: "border-box"
|
|
13013
|
+
},
|
|
13014
|
+
children
|
|
13015
|
+
}
|
|
13016
|
+
)
|
|
13017
|
+
]
|
|
13018
|
+
}
|
|
13019
|
+
);
|
|
13020
|
+
};
|
|
12725
13021
|
const DragOptions = ({
|
|
12726
13022
|
options,
|
|
12727
13023
|
onOptionClick
|
|
12728
13024
|
}) => {
|
|
12729
13025
|
const { usedOptionIds } = useExerciseDnd();
|
|
12730
|
-
return /* @__PURE__ */ jsx("div", { className: "drag-options", children: options.map((option, idx) => {
|
|
13026
|
+
return /* @__PURE__ */ jsx(PillBackground, { children: /* @__PURE__ */ jsx("div", { className: "drag-options", children: options.map((option, idx) => {
|
|
12731
13027
|
const optionId = `drag-option-${idx}`;
|
|
12732
13028
|
const optionValue = String(option);
|
|
12733
13029
|
const isUsed = usedOptionIds.has(optionId);
|
|
@@ -12754,7 +13050,7 @@ const DragOptions = ({
|
|
|
12754
13050
|
}
|
|
12755
13051
|
)
|
|
12756
13052
|
) }, optionId);
|
|
12757
|
-
}) });
|
|
13053
|
+
}) }) });
|
|
12758
13054
|
};
|
|
12759
13055
|
const NumericKeypad = ({
|
|
12760
13056
|
value,
|
|
@@ -12850,28 +13146,6 @@ const FractionInput = ({
|
|
|
12850
13146
|
/* @__PURE__ */ jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
|
|
12851
13147
|
] });
|
|
12852
13148
|
};
|
|
12853
|
-
const RadioOptions = ({
|
|
12854
|
-
name,
|
|
12855
|
-
options,
|
|
12856
|
-
value,
|
|
12857
|
-
validationStatus = null,
|
|
12858
|
-
onChange
|
|
12859
|
-
}) => {
|
|
12860
|
-
return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
12861
|
-
const isSelected = value === option;
|
|
12862
|
-
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
12863
|
-
return /* @__PURE__ */ jsx(
|
|
12864
|
-
"button",
|
|
12865
|
-
{
|
|
12866
|
-
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
12867
|
-
onClick: () => onChange(name, option),
|
|
12868
|
-
type: "button",
|
|
12869
|
-
children: option
|
|
12870
|
-
},
|
|
12871
|
-
`${name}-radio-${idx}`
|
|
12872
|
-
);
|
|
12873
|
-
}) });
|
|
12874
|
-
};
|
|
12875
13149
|
function isLightColor(hex) {
|
|
12876
13150
|
const clean = hex.replace("#", "");
|
|
12877
13151
|
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
@@ -12886,7 +13160,8 @@ function isLightColor(hex) {
|
|
|
12886
13160
|
return 1.05 / (L + 0.05) < 4.5;
|
|
12887
13161
|
}
|
|
12888
13162
|
function getTextColorForBackground(color) {
|
|
12889
|
-
|
|
13163
|
+
const hslLightness = parseHslLightness(color);
|
|
13164
|
+
if (hslLightness !== null) return hslLightness >= 60 ? "#1a1a1a" : "#ffffff";
|
|
12890
13165
|
return isLightColor(color) ? "#1a1a1a" : "#ffffff";
|
|
12891
13166
|
}
|
|
12892
13167
|
function generateHslPalette(n) {
|
|
@@ -12895,7 +13170,7 @@ function generateHslPalette(n) {
|
|
|
12895
13170
|
return `hsl(${hue}, 60%, 72%)`;
|
|
12896
13171
|
});
|
|
12897
13172
|
}
|
|
12898
|
-
function
|
|
13173
|
+
function resolveGroupPalette(userColors, groupCount) {
|
|
12899
13174
|
if (groupCount <= 0) return [];
|
|
12900
13175
|
if (!userColors || userColors.length === 0) {
|
|
12901
13176
|
return generateHslPalette(groupCount);
|
|
@@ -12907,6 +13182,478 @@ function resolveMatcherPalette(userColors, groupCount) {
|
|
|
12907
13182
|
const extras = generateHslPalette(needed);
|
|
12908
13183
|
return [...userColors, ...extras];
|
|
12909
13184
|
}
|
|
13185
|
+
function resolveDistinctGroupPalette(groupCount, options = {}) {
|
|
13186
|
+
if (groupCount <= 0) return [];
|
|
13187
|
+
const used = new Set((options.excludeColors ?? []).map(normalizeColorToken));
|
|
13188
|
+
const palette = [];
|
|
13189
|
+
for (const color of options.seedColors ?? []) {
|
|
13190
|
+
const token = normalizeColorToken(color);
|
|
13191
|
+
if (used.has(token)) continue;
|
|
13192
|
+
palette.push(color);
|
|
13193
|
+
used.add(token);
|
|
13194
|
+
if (palette.length === groupCount) return palette;
|
|
13195
|
+
}
|
|
13196
|
+
const addCandidates = (candidates) => {
|
|
13197
|
+
for (const color of candidates) {
|
|
13198
|
+
const token = normalizeColorToken(color);
|
|
13199
|
+
if (used.has(token)) continue;
|
|
13200
|
+
palette.push(color);
|
|
13201
|
+
used.add(token);
|
|
13202
|
+
if (palette.length === groupCount) return;
|
|
13203
|
+
}
|
|
13204
|
+
};
|
|
13205
|
+
addCandidates(generateHslPalette(groupCount));
|
|
13206
|
+
let candidateCount = groupCount + 1;
|
|
13207
|
+
while (palette.length < groupCount) {
|
|
13208
|
+
addCandidates(generateHslPalette(candidateCount++));
|
|
13209
|
+
}
|
|
13210
|
+
return palette;
|
|
13211
|
+
}
|
|
13212
|
+
function getGroupClassName(groupIndex) {
|
|
13213
|
+
return `widget-group-${groupIndex + 1}`;
|
|
13214
|
+
}
|
|
13215
|
+
function normalizeColorToken(color) {
|
|
13216
|
+
return color.replace(/\s+/g, "").toLowerCase();
|
|
13217
|
+
}
|
|
13218
|
+
function parseHslLightness(color) {
|
|
13219
|
+
const match = color.match(/^hsl\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*([\d.]+)%\s*\)$/i);
|
|
13220
|
+
return match ? Number(match[1]) : null;
|
|
13221
|
+
}
|
|
13222
|
+
function sumCounts(counts) {
|
|
13223
|
+
return counts.reduce((sum, count) => sum + count, 0);
|
|
13224
|
+
}
|
|
13225
|
+
function countsToPartGroups(counts) {
|
|
13226
|
+
return counts.flatMap(
|
|
13227
|
+
(count, groupIndex) => Array.from({ length: count }, () => groupIndex)
|
|
13228
|
+
);
|
|
13229
|
+
}
|
|
13230
|
+
function normalizeCounts(value, expected) {
|
|
13231
|
+
if (!Array.isArray(value)) return expected.map(() => 0);
|
|
13232
|
+
return expected.map((max, i) => Math.max(0, Math.min(max, value[i] ?? 0)));
|
|
13233
|
+
}
|
|
13234
|
+
function serializeCounts(value) {
|
|
13235
|
+
return Array.isArray(value) ? value.join("|") : null;
|
|
13236
|
+
}
|
|
13237
|
+
function countsToAssignments(counts, divisions, filledCount) {
|
|
13238
|
+
const assignments = Array.from({ length: divisions }, () => null);
|
|
13239
|
+
let index = filledCount;
|
|
13240
|
+
counts.forEach((count, groupIndex) => {
|
|
13241
|
+
for (let i = 0; i < count && index < divisions; i++) {
|
|
13242
|
+
assignments[index] = groupIndex;
|
|
13243
|
+
index++;
|
|
13244
|
+
}
|
|
13245
|
+
});
|
|
13246
|
+
return assignments;
|
|
13247
|
+
}
|
|
13248
|
+
function getNextAssignments(assignments, partIndex, groupCount) {
|
|
13249
|
+
if (groupCount === 0) return [...assignments];
|
|
13250
|
+
const current = assignments[partIndex] ?? null;
|
|
13251
|
+
const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
|
|
13252
|
+
const updated = [...assignments];
|
|
13253
|
+
updated[partIndex] = next;
|
|
13254
|
+
return updated;
|
|
13255
|
+
}
|
|
13256
|
+
function assignmentsToCounts(assignments, groupCount) {
|
|
13257
|
+
const counts = Array.from({ length: groupCount }, () => 0);
|
|
13258
|
+
for (const groupIndex of assignments) {
|
|
13259
|
+
if (groupIndex !== null && groupIndex >= 0 && groupIndex < groupCount) {
|
|
13260
|
+
counts[groupIndex] = (counts[groupIndex] ?? 0) + 1;
|
|
13261
|
+
}
|
|
13262
|
+
}
|
|
13263
|
+
return counts;
|
|
13264
|
+
}
|
|
13265
|
+
function useVisualFractionState({
|
|
13266
|
+
widgetId,
|
|
13267
|
+
divisions,
|
|
13268
|
+
filledCount,
|
|
13269
|
+
answer,
|
|
13270
|
+
value,
|
|
13271
|
+
onChange
|
|
13272
|
+
}) {
|
|
13273
|
+
const [partAssignments, setPartAssignments] = useState(
|
|
13274
|
+
() => countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
|
|
13275
|
+
);
|
|
13276
|
+
const lastInternalValueRef = useRef(null);
|
|
13277
|
+
const valueKey = serializeCounts(value);
|
|
13278
|
+
useEffect(() => {
|
|
13279
|
+
if (valueKey !== null && valueKey === lastInternalValueRef.current) return;
|
|
13280
|
+
lastInternalValueRef.current = null;
|
|
13281
|
+
setPartAssignments(
|
|
13282
|
+
countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
|
|
13283
|
+
);
|
|
13284
|
+
}, [answer, divisions, filledCount, valueKey]);
|
|
13285
|
+
const handlePartClick = (partIndex) => {
|
|
13286
|
+
if (partIndex < filledCount) return;
|
|
13287
|
+
const updated = getNextAssignments(partAssignments, partIndex, answer.length);
|
|
13288
|
+
const nextCounts = assignmentsToCounts(updated, answer.length);
|
|
13289
|
+
lastInternalValueRef.current = serializeCounts(nextCounts);
|
|
13290
|
+
setPartAssignments(updated);
|
|
13291
|
+
onChange(widgetId, nextCounts);
|
|
13292
|
+
};
|
|
13293
|
+
return { partAssignments, handlePartClick };
|
|
13294
|
+
}
|
|
13295
|
+
const VISUAL_FRACTION_DISPLAY_COLOR = "#AE9CFF";
|
|
13296
|
+
const VISUAL_FRACTION_BORDER_COLOR = "#705DC0";
|
|
13297
|
+
const VISUAL_FRACTION_SVG_SIZE_SCALE = 8;
|
|
13298
|
+
function getCircleSectorProps(index, divisions) {
|
|
13299
|
+
if (divisions <= 1) return { kind: "circle" };
|
|
13300
|
+
const angle = 360 / divisions;
|
|
13301
|
+
const startAngle = -90 + index * angle;
|
|
13302
|
+
const endAngle = startAngle + angle;
|
|
13303
|
+
const start = polarToCartesian(50, 50, 48, endAngle);
|
|
13304
|
+
const end = polarToCartesian(50, 50, 48, startAngle);
|
|
13305
|
+
const largeArcFlag = angle > 180 ? 1 : 0;
|
|
13306
|
+
return {
|
|
13307
|
+
kind: "path",
|
|
13308
|
+
d: [
|
|
13309
|
+
"M",
|
|
13310
|
+
50,
|
|
13311
|
+
50,
|
|
13312
|
+
"L",
|
|
13313
|
+
start.x,
|
|
13314
|
+
start.y,
|
|
13315
|
+
"A",
|
|
13316
|
+
48,
|
|
13317
|
+
48,
|
|
13318
|
+
0,
|
|
13319
|
+
largeArcFlag,
|
|
13320
|
+
0,
|
|
13321
|
+
end.x,
|
|
13322
|
+
end.y,
|
|
13323
|
+
"Z"
|
|
13324
|
+
].join(" ")
|
|
13325
|
+
};
|
|
13326
|
+
}
|
|
13327
|
+
function polarToCartesian(cx, cy, r, angleInDegrees) {
|
|
13328
|
+
const rad = angleInDegrees * Math.PI / 180;
|
|
13329
|
+
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
|
13330
|
+
}
|
|
13331
|
+
function getEqualTrianglePartPath(index, divisions) {
|
|
13332
|
+
const side = Math.sqrt(divisions);
|
|
13333
|
+
if (!Number.isInteger(side)) {
|
|
13334
|
+
throw new Error(`getEqualTrianglePartPath: divisions (${divisions}) must be a perfect square.`);
|
|
13335
|
+
}
|
|
13336
|
+
const smallSide = 96 / side;
|
|
13337
|
+
const smallHeight = smallSide * Math.sqrt(3) / 2;
|
|
13338
|
+
const totalHeight = side * smallHeight;
|
|
13339
|
+
const offsetY = (100 - totalHeight) / 2;
|
|
13340
|
+
let cursor = 0;
|
|
13341
|
+
for (let row = 0; row < side; row++) {
|
|
13342
|
+
const y = offsetY + row * smallHeight;
|
|
13343
|
+
const rowLeft = 50 - (row + 1) * smallSide / 2;
|
|
13344
|
+
for (let col = 0; col <= row; col++) {
|
|
13345
|
+
if (cursor === index) {
|
|
13346
|
+
const x = rowLeft + col * smallSide;
|
|
13347
|
+
return ["M", x + smallSide / 2, y, "L", x + smallSide, y + smallHeight, "L", x, y + smallHeight, "Z"].join(" ");
|
|
13348
|
+
}
|
|
13349
|
+
cursor++;
|
|
13350
|
+
}
|
|
13351
|
+
for (let col = 0; col < row; col++) {
|
|
13352
|
+
if (cursor === index) {
|
|
13353
|
+
const x = rowLeft + col * smallSide;
|
|
13354
|
+
return ["M", x + smallSide, y + smallHeight, "L", x + smallSide / 2, y, "L", x + smallSide * 1.5, y, "Z"].join(" ");
|
|
13355
|
+
}
|
|
13356
|
+
cursor++;
|
|
13357
|
+
}
|
|
13358
|
+
}
|
|
13359
|
+
return "";
|
|
13360
|
+
}
|
|
13361
|
+
function getGridColumnCount(divisions) {
|
|
13362
|
+
return Math.ceil(Math.sqrt(divisions));
|
|
13363
|
+
}
|
|
13364
|
+
function getInteractiveProps(part, onPartClick) {
|
|
13365
|
+
if (!part.isInteractiveSlot) return {};
|
|
13366
|
+
return {
|
|
13367
|
+
onClick: () => onPartClick(part.index),
|
|
13368
|
+
role: "button",
|
|
13369
|
+
"aria-pressed": part.isSelected,
|
|
13370
|
+
"aria-label": getPartAriaLabel(part),
|
|
13371
|
+
tabIndex: 0,
|
|
13372
|
+
onKeyDown: (event) => {
|
|
13373
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
13374
|
+
event.preventDefault();
|
|
13375
|
+
onPartClick(part.index);
|
|
13376
|
+
}
|
|
13377
|
+
}
|
|
13378
|
+
};
|
|
13379
|
+
}
|
|
13380
|
+
function getPartAriaLabel(part) {
|
|
13381
|
+
return part.groupIndex === null ? `Part ${part.index + 1}, empty` : `Part ${part.index + 1}, group ${part.groupIndex + 1}`;
|
|
13382
|
+
}
|
|
13383
|
+
const BarShape = ({
|
|
13384
|
+
parts,
|
|
13385
|
+
divisions,
|
|
13386
|
+
orientation,
|
|
13387
|
+
onPartClick
|
|
13388
|
+
}) => {
|
|
13389
|
+
const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
|
|
13390
|
+
return /* @__PURE__ */ jsx(
|
|
13391
|
+
"div",
|
|
13392
|
+
{
|
|
13393
|
+
className: `visual-fraction-widget__parts${orientation === "vertical" ? " visual-fraction-widget__parts--vertical" : " visual-fraction-widget__parts--horizontal"}`,
|
|
13394
|
+
style: { "--visual-fraction-divisions": divisions },
|
|
13395
|
+
children: renderedParts.map((part) => /* @__PURE__ */ jsx(
|
|
13396
|
+
"div",
|
|
13397
|
+
{
|
|
13398
|
+
className: part.className,
|
|
13399
|
+
style: part.style,
|
|
13400
|
+
...getInteractiveProps(part, onPartClick)
|
|
13401
|
+
},
|
|
13402
|
+
part.index
|
|
13403
|
+
))
|
|
13404
|
+
}
|
|
13405
|
+
);
|
|
13406
|
+
};
|
|
13407
|
+
BarShape.displayName = "BarShape";
|
|
13408
|
+
const GridShape = ({
|
|
13409
|
+
parts,
|
|
13410
|
+
divisions,
|
|
13411
|
+
onPartClick
|
|
13412
|
+
}) => /* @__PURE__ */ jsx(
|
|
13413
|
+
"div",
|
|
13414
|
+
{
|
|
13415
|
+
className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
|
|
13416
|
+
style: {
|
|
13417
|
+
"--visual-fraction-divisions": divisions,
|
|
13418
|
+
"--visual-fraction-grid-cols": getGridColumnCount(divisions)
|
|
13419
|
+
},
|
|
13420
|
+
children: parts.map((part) => /* @__PURE__ */ jsx(
|
|
13421
|
+
"div",
|
|
13422
|
+
{
|
|
13423
|
+
className: part.className,
|
|
13424
|
+
style: part.style,
|
|
13425
|
+
...getInteractiveProps(part, onPartClick)
|
|
13426
|
+
},
|
|
13427
|
+
part.index
|
|
13428
|
+
))
|
|
13429
|
+
}
|
|
13430
|
+
);
|
|
13431
|
+
GridShape.displayName = "GridShape";
|
|
13432
|
+
const CircleShape = ({
|
|
13433
|
+
parts,
|
|
13434
|
+
divisions,
|
|
13435
|
+
onPartClick
|
|
13436
|
+
}) => /* @__PURE__ */ jsx(
|
|
13437
|
+
"svg",
|
|
13438
|
+
{
|
|
13439
|
+
className: "visual-fraction-widget__shape visual-fraction-widget__circle",
|
|
13440
|
+
viewBox: "0 0 100 100",
|
|
13441
|
+
children: parts.map((part) => {
|
|
13442
|
+
const sectorProps = getCircleSectorProps(part.index, divisions);
|
|
13443
|
+
const className = `${part.className} visual-fraction-widget__sector`;
|
|
13444
|
+
const interactiveProps = getInteractiveProps(part, onPartClick);
|
|
13445
|
+
return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
|
|
13446
|
+
"circle",
|
|
13447
|
+
{
|
|
13448
|
+
className,
|
|
13449
|
+
style: part.style,
|
|
13450
|
+
cx: "50",
|
|
13451
|
+
cy: "50",
|
|
13452
|
+
r: "48",
|
|
13453
|
+
...interactiveProps
|
|
13454
|
+
},
|
|
13455
|
+
part.index
|
|
13456
|
+
) : /* @__PURE__ */ jsx(
|
|
13457
|
+
"path",
|
|
13458
|
+
{
|
|
13459
|
+
className,
|
|
13460
|
+
style: part.style,
|
|
13461
|
+
d: sectorProps.d,
|
|
13462
|
+
...interactiveProps
|
|
13463
|
+
},
|
|
13464
|
+
part.index
|
|
13465
|
+
);
|
|
13466
|
+
})
|
|
13467
|
+
}
|
|
13468
|
+
);
|
|
13469
|
+
CircleShape.displayName = "CircleShape";
|
|
13470
|
+
const TriangleShape = ({
|
|
13471
|
+
parts,
|
|
13472
|
+
divisions,
|
|
13473
|
+
onPartClick
|
|
13474
|
+
}) => /* @__PURE__ */ jsx(
|
|
13475
|
+
"svg",
|
|
13476
|
+
{
|
|
13477
|
+
className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
|
|
13478
|
+
viewBox: "0 0 100 100",
|
|
13479
|
+
children: parts.map((part) => /* @__PURE__ */ jsx(
|
|
13480
|
+
"path",
|
|
13481
|
+
{
|
|
13482
|
+
className: `${part.className} visual-fraction-widget__sector`,
|
|
13483
|
+
style: part.style,
|
|
13484
|
+
d: getEqualTrianglePartPath(part.index, divisions),
|
|
13485
|
+
...getInteractiveProps(part, onPartClick)
|
|
13486
|
+
},
|
|
13487
|
+
part.index
|
|
13488
|
+
))
|
|
13489
|
+
}
|
|
13490
|
+
);
|
|
13491
|
+
TriangleShape.displayName = "TriangleShape";
|
|
13492
|
+
const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsx(BarShape, { parts, divisions, orientation, onPartClick });
|
|
13493
|
+
const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(CircleShape, { parts, divisions, onPartClick });
|
|
13494
|
+
const ShapeTriangle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(TriangleShape, { parts, divisions, onPartClick });
|
|
13495
|
+
const ShapeGrid = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(GridShape, { parts, divisions, onPartClick });
|
|
13496
|
+
const SHAPE_COMPONENTS = {
|
|
13497
|
+
[VISUAL_FRACTION_SHAPE.BAR]: ShapeBar,
|
|
13498
|
+
[VISUAL_FRACTION_SHAPE.CIRCLE]: ShapeCircle,
|
|
13499
|
+
[VISUAL_FRACTION_SHAPE.TRIANGLE]: ShapeTriangle,
|
|
13500
|
+
[VISUAL_FRACTION_SHAPE.GRID]: ShapeGrid
|
|
13501
|
+
};
|
|
13502
|
+
const VisualFraction = ({
|
|
13503
|
+
widgetId,
|
|
13504
|
+
widget,
|
|
13505
|
+
value,
|
|
13506
|
+
validationStatus = null,
|
|
13507
|
+
onChange
|
|
13508
|
+
}) => {
|
|
13509
|
+
const { shape, divisions, filled, answer, orientation, label } = widget;
|
|
13510
|
+
const filledCount = sumCounts(filled);
|
|
13511
|
+
const filledPartGroups = useMemo(() => countsToPartGroups(filled), [filled]);
|
|
13512
|
+
const filledPalette = useMemo(() => {
|
|
13513
|
+
if (filled.length === 0) return [];
|
|
13514
|
+
return resolveDistinctGroupPalette(filled.length, {
|
|
13515
|
+
seedColors: [VISUAL_FRACTION_DISPLAY_COLOR],
|
|
13516
|
+
excludeColors: [VISUAL_FRACTION_BORDER_COLOR]
|
|
13517
|
+
});
|
|
13518
|
+
}, [filled]);
|
|
13519
|
+
const answerPalette = useMemo(() => {
|
|
13520
|
+
if (answer.length === 0) return [];
|
|
13521
|
+
return resolveDistinctGroupPalette(answer.length, {
|
|
13522
|
+
excludeColors: [VISUAL_FRACTION_BORDER_COLOR, ...filledPalette]
|
|
13523
|
+
});
|
|
13524
|
+
}, [answer, filledPalette]);
|
|
13525
|
+
const { partAssignments, handlePartClick } = useVisualFractionState({
|
|
13526
|
+
widgetId,
|
|
13527
|
+
divisions,
|
|
13528
|
+
filledCount,
|
|
13529
|
+
answer,
|
|
13530
|
+
value,
|
|
13531
|
+
onChange
|
|
13532
|
+
});
|
|
13533
|
+
const parts = useMemo(
|
|
13534
|
+
() => Array.from({ length: divisions }, (_, i) => {
|
|
13535
|
+
let partClass = "visual-fraction-widget__part visual-fraction-widget__part--empty";
|
|
13536
|
+
let groupIdx = null;
|
|
13537
|
+
if (i < filledCount) {
|
|
13538
|
+
groupIdx = filledPartGroups[i] ?? 0;
|
|
13539
|
+
partClass = "visual-fraction-widget__part visual-fraction-widget__part--filled";
|
|
13540
|
+
} else if (partAssignments[i] != null) {
|
|
13541
|
+
groupIdx = partAssignments[i];
|
|
13542
|
+
partClass = "visual-fraction-widget__part visual-fraction-widget__part--answer";
|
|
13543
|
+
}
|
|
13544
|
+
if (groupIdx !== null) {
|
|
13545
|
+
partClass += ` visual-fraction-widget__part--group-${groupIdx + 1} ${getGroupClassName(groupIdx)}`;
|
|
13546
|
+
}
|
|
13547
|
+
const colorKind = i < filledCount ? "filled" : "answer";
|
|
13548
|
+
const palette = colorKind === "filled" ? filledPalette : answerPalette;
|
|
13549
|
+
const fallbackColor = palette[groupIdx ?? 0];
|
|
13550
|
+
const style = groupIdx !== null ? {
|
|
13551
|
+
"--visual-fraction-part-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-color, ${fallbackColor})`,
|
|
13552
|
+
"--visual-fraction-part-text-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-text-color, ${getTextColorForBackground(fallbackColor)})`
|
|
13553
|
+
} : void 0;
|
|
13554
|
+
return {
|
|
13555
|
+
index: i,
|
|
13556
|
+
className: partClass,
|
|
13557
|
+
style,
|
|
13558
|
+
// A slot is interactive when there are answer groups AND it falls in the non-filled range.
|
|
13559
|
+
// answer.length counts groups (not total slots); the state hook handles per-slot cycling.
|
|
13560
|
+
isInteractiveSlot: answer.length > 0 && i >= filledCount,
|
|
13561
|
+
isSelected: groupIdx !== null,
|
|
13562
|
+
groupIndex: groupIdx
|
|
13563
|
+
};
|
|
13564
|
+
}),
|
|
13565
|
+
[divisions, filledCount, filledPartGroups, partAssignments, answer, filledPalette, answerPalette]
|
|
13566
|
+
);
|
|
13567
|
+
const statusClass = validationStatus ? ` visual-fraction-widget--${validationStatus} widget--${validationStatus}` : "";
|
|
13568
|
+
const rootStyle = useMemo(() => getSizeStyle(widget.size, shape), [widget.size, shape]);
|
|
13569
|
+
const ShapeComponent = SHAPE_COMPONENTS[shape];
|
|
13570
|
+
return /* @__PURE__ */ jsxs(
|
|
13571
|
+
"div",
|
|
13572
|
+
{
|
|
13573
|
+
className: `visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
|
|
13574
|
+
style: rootStyle,
|
|
13575
|
+
role: "group",
|
|
13576
|
+
"aria-label": `${divisions} part ${shape} fraction`,
|
|
13577
|
+
children: [
|
|
13578
|
+
/* @__PURE__ */ jsx(
|
|
13579
|
+
ShapeComponent,
|
|
13580
|
+
{
|
|
13581
|
+
parts,
|
|
13582
|
+
divisions,
|
|
13583
|
+
orientation: orientation ?? "horizontal",
|
|
13584
|
+
onPartClick: handlePartClick
|
|
13585
|
+
}
|
|
13586
|
+
),
|
|
13587
|
+
label && /* @__PURE__ */ jsx("span", { className: "visual-fraction-widget__label", children: label })
|
|
13588
|
+
]
|
|
13589
|
+
}
|
|
13590
|
+
);
|
|
13591
|
+
};
|
|
13592
|
+
VisualFraction.displayName = "VisualFraction";
|
|
13593
|
+
function getSizeStyle(size, shape) {
|
|
13594
|
+
if (size === void 0) return void 0;
|
|
13595
|
+
if (shape === "circle" || shape === "triangle") {
|
|
13596
|
+
return {
|
|
13597
|
+
"--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
|
|
13598
|
+
};
|
|
13599
|
+
}
|
|
13600
|
+
return {
|
|
13601
|
+
"--visual-fraction-part-size": `${size}rem`
|
|
13602
|
+
};
|
|
13603
|
+
}
|
|
13604
|
+
const RadioOptions = ({
|
|
13605
|
+
name,
|
|
13606
|
+
options,
|
|
13607
|
+
value,
|
|
13608
|
+
validationStatus = null,
|
|
13609
|
+
onChange
|
|
13610
|
+
}) => {
|
|
13611
|
+
return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
13612
|
+
const isSelected = value === option;
|
|
13613
|
+
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
13614
|
+
return /* @__PURE__ */ jsx(
|
|
13615
|
+
"button",
|
|
13616
|
+
{
|
|
13617
|
+
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
13618
|
+
onClick: () => onChange(name, option),
|
|
13619
|
+
type: "button",
|
|
13620
|
+
children: option
|
|
13621
|
+
},
|
|
13622
|
+
`${name}-radio-${idx}`
|
|
13623
|
+
);
|
|
13624
|
+
}) });
|
|
13625
|
+
};
|
|
13626
|
+
const Btn = ({
|
|
13627
|
+
label,
|
|
13628
|
+
extraClass = "keypad-btn-digit",
|
|
13629
|
+
onClick
|
|
13630
|
+
}) => /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
|
|
13631
|
+
const DiamondKeypad = ({ value, onChange }) => {
|
|
13632
|
+
const append = (ch) => onChange(value + ch);
|
|
13633
|
+
const backspace = () => onChange(value.slice(0, -1));
|
|
13634
|
+
return /* @__PURE__ */ jsxs("div", { className: "diamond-keypad", children: [
|
|
13635
|
+
/* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13636
|
+
/* @__PURE__ */ jsx(Btn, { label: "1", onClick: () => append("1") }),
|
|
13637
|
+
/* @__PURE__ */ jsx(Btn, { label: "2", onClick: () => append("2") }),
|
|
13638
|
+
/* @__PURE__ */ jsx(Btn, { label: "3", onClick: () => append("3") })
|
|
13639
|
+
] }),
|
|
13640
|
+
/* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13641
|
+
/* @__PURE__ */ jsx(Btn, { label: "4", onClick: () => append("4") }),
|
|
13642
|
+
/* @__PURE__ */ jsx(Btn, { label: "5", onClick: () => append("5") }),
|
|
13643
|
+
/* @__PURE__ */ jsx(Btn, { label: "6", onClick: () => append("6") }),
|
|
13644
|
+
/* @__PURE__ */ jsx(Btn, { label: "7", onClick: () => append("7") })
|
|
13645
|
+
] }),
|
|
13646
|
+
/* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13647
|
+
/* @__PURE__ */ jsx(Btn, { label: "8", onClick: () => append("8") }),
|
|
13648
|
+
/* @__PURE__ */ jsx(Btn, { label: "9", onClick: () => append("9") }),
|
|
13649
|
+
/* @__PURE__ */ jsx(Btn, { label: "0", onClick: () => append("0") })
|
|
13650
|
+
] }),
|
|
13651
|
+
/* @__PURE__ */ jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13652
|
+
/* @__PURE__ */ jsx(Btn, { label: ".", extraClass: "keypad-btn-decimal", onClick: () => append(".") }),
|
|
13653
|
+
/* @__PURE__ */ jsx(Btn, { label: "⌫", extraClass: "keypad-btn-backspace", onClick: backspace })
|
|
13654
|
+
] })
|
|
13655
|
+
] });
|
|
13656
|
+
};
|
|
12910
13657
|
function collectItemIds(rows) {
|
|
12911
13658
|
return rows.flatMap((row) => row.items.map((item) => item.itemId));
|
|
12912
13659
|
}
|
|
@@ -12948,7 +13695,7 @@ const MatcherWidget = ({
|
|
|
12948
13695
|
const { rows, colors: userColors, groupCount } = widget;
|
|
12949
13696
|
const widgetsMap = widgets ?? EMPTY_WIDGETS;
|
|
12950
13697
|
const palette = useMemo(
|
|
12951
|
-
() =>
|
|
13698
|
+
() => resolveGroupPalette(userColors, groupCount),
|
|
12952
13699
|
[userColors, groupCount]
|
|
12953
13700
|
);
|
|
12954
13701
|
const textColors = useMemo(
|
|
@@ -12964,13 +13711,21 @@ const MatcherWidget = ({
|
|
|
12964
13711
|
setAssignments((prev) => {
|
|
12965
13712
|
const current = prev[itemId] ?? null;
|
|
12966
13713
|
const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
|
|
12967
|
-
|
|
12968
|
-
onChange(widgetId, buildGroups(updated));
|
|
12969
|
-
return updated;
|
|
13714
|
+
return { ...prev, [itemId]: next };
|
|
12970
13715
|
});
|
|
12971
13716
|
},
|
|
12972
|
-
[groupCount
|
|
13717
|
+
[groupCount]
|
|
12973
13718
|
);
|
|
13719
|
+
const onChangeRef = useRef(onChange);
|
|
13720
|
+
onChangeRef.current = onChange;
|
|
13721
|
+
const isMounted = useRef(false);
|
|
13722
|
+
useEffect(() => {
|
|
13723
|
+
if (!isMounted.current) {
|
|
13724
|
+
isMounted.current = true;
|
|
13725
|
+
return;
|
|
13726
|
+
}
|
|
13727
|
+
onChangeRef.current(widgetId, buildGroups(assignments));
|
|
13728
|
+
}, [assignments, widgetId]);
|
|
12974
13729
|
const maxCols = useMemo(
|
|
12975
13730
|
() => rows.length === 0 ? 1 : Math.max(...rows.map((r) => r.items.length)),
|
|
12976
13731
|
[rows]
|
|
@@ -12991,9 +13746,9 @@ const MatcherWidget = ({
|
|
|
12991
13746
|
{
|
|
12992
13747
|
type: "button",
|
|
12993
13748
|
className: `matcher-card${isSelected ? " matcher-card--selected" : ""}`,
|
|
12994
|
-
style: color ? {
|
|
12995
|
-
"--matcher-card-color": color,
|
|
12996
|
-
"--matcher-card-text-color": textColors[colorIdx]
|
|
13749
|
+
style: colorIdx !== null && color ? {
|
|
13750
|
+
"--matcher-card-color": `var(--matcher-group-${colorIdx + 1}-color, ${color})`,
|
|
13751
|
+
"--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
|
|
12997
13752
|
} : void 0,
|
|
12998
13753
|
onClick: () => handleCardClick(item.itemId),
|
|
12999
13754
|
"aria-pressed": isSelected,
|
|
@@ -13035,6 +13790,9 @@ function isFractionWidget(widget) {
|
|
|
13035
13790
|
function isMatcherWidget(widget) {
|
|
13036
13791
|
return widget.type === "matcher";
|
|
13037
13792
|
}
|
|
13793
|
+
function isVisualFractionWidget(widget) {
|
|
13794
|
+
return widget.type === "visual-fraction";
|
|
13795
|
+
}
|
|
13038
13796
|
const WidgetRenderer = ({
|
|
13039
13797
|
widget,
|
|
13040
13798
|
widgetId,
|
|
@@ -13060,6 +13818,7 @@ const WidgetRenderer = ({
|
|
|
13060
13818
|
inputType: widget.inputType,
|
|
13061
13819
|
length: widget.inputType === "string" ? widget.length : void 0,
|
|
13062
13820
|
digits: widget.inputType === "numeric" ? widget.digits : void 0,
|
|
13821
|
+
placeholder: widget.placeholder,
|
|
13063
13822
|
onChange,
|
|
13064
13823
|
onFocus: onInputFocus
|
|
13065
13824
|
}
|
|
@@ -13088,14 +13847,24 @@ const WidgetRenderer = ({
|
|
|
13088
13847
|
);
|
|
13089
13848
|
}
|
|
13090
13849
|
if (isNumericKeypadWidget(widget)) {
|
|
13850
|
+
const keypadOnChange = onKeypadChange || (() => {
|
|
13851
|
+
});
|
|
13852
|
+
if (widget.style === "classic") {
|
|
13853
|
+
return /* @__PURE__ */ jsx(
|
|
13854
|
+
NumericKeypad,
|
|
13855
|
+
{
|
|
13856
|
+
value: strValue,
|
|
13857
|
+
includeOperators: widget.includeOperators,
|
|
13858
|
+
includeDecimal: widget.includeDecimal,
|
|
13859
|
+
onChange: keypadOnChange
|
|
13860
|
+
}
|
|
13861
|
+
);
|
|
13862
|
+
}
|
|
13091
13863
|
return /* @__PURE__ */ jsx(
|
|
13092
|
-
|
|
13864
|
+
DiamondKeypad,
|
|
13093
13865
|
{
|
|
13094
13866
|
value: strValue,
|
|
13095
|
-
|
|
13096
|
-
includeDecimal: widget.includeDecimal,
|
|
13097
|
-
onChange: onKeypadChange || (() => {
|
|
13098
|
-
})
|
|
13867
|
+
onChange: keypadOnChange
|
|
13099
13868
|
}
|
|
13100
13869
|
);
|
|
13101
13870
|
}
|
|
@@ -13127,6 +13896,18 @@ const WidgetRenderer = ({
|
|
|
13127
13896
|
}
|
|
13128
13897
|
);
|
|
13129
13898
|
}
|
|
13899
|
+
if (isVisualFractionWidget(widget)) {
|
|
13900
|
+
return /* @__PURE__ */ jsx(
|
|
13901
|
+
VisualFraction,
|
|
13902
|
+
{
|
|
13903
|
+
widgetId,
|
|
13904
|
+
widget,
|
|
13905
|
+
value: Array.isArray(value) && value.every((v) => typeof v === "number") ? value : void 0,
|
|
13906
|
+
validationStatus,
|
|
13907
|
+
onChange: (id, nextValue) => onChange(id, nextValue)
|
|
13908
|
+
}
|
|
13909
|
+
);
|
|
13910
|
+
}
|
|
13130
13911
|
return null;
|
|
13131
13912
|
};
|
|
13132
13913
|
const EPSILON = 10 ** -INTERNAL_PRECISION;
|
|
@@ -13629,6 +14410,102 @@ const CounterControls = ({
|
|
|
13629
14410
|
] }),
|
|
13630
14411
|
counter.name && /* @__PURE__ */ jsx("span", { className: "number-line-counter-label", children: counter.name })
|
|
13631
14412
|
] });
|
|
14413
|
+
const CONTENT_ROW_EXTRA_SPACE = 8;
|
|
14414
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
14415
|
+
function useMeasuredPositionContentHeight(positionIds) {
|
|
14416
|
+
const [heightPx, setHeightPx] = useState(null);
|
|
14417
|
+
const elementsRef = useRef(/* @__PURE__ */ new Map());
|
|
14418
|
+
const refCallbacksRef = useRef(/* @__PURE__ */ new Map());
|
|
14419
|
+
const observerRef = useRef(null);
|
|
14420
|
+
const measureFrameRef = useRef(null);
|
|
14421
|
+
const idsKey = positionIds.join("|");
|
|
14422
|
+
const runMeasure = useCallback(() => {
|
|
14423
|
+
let maxHeight = 0;
|
|
14424
|
+
for (const element of elementsRef.current.values()) {
|
|
14425
|
+
const rectHeight = element.getBoundingClientRect().height;
|
|
14426
|
+
const measuredHeight = Math.max(rectHeight, element.offsetHeight);
|
|
14427
|
+
maxHeight = Math.max(maxHeight, measuredHeight);
|
|
14428
|
+
}
|
|
14429
|
+
const nextHeight = maxHeight > 0 ? Math.ceil(maxHeight + CONTENT_ROW_EXTRA_SPACE) : null;
|
|
14430
|
+
setHeightPx((current) => current === nextHeight ? current : nextHeight);
|
|
14431
|
+
}, []);
|
|
14432
|
+
const measure = useCallback(() => {
|
|
14433
|
+
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
|
|
14434
|
+
runMeasure();
|
|
14435
|
+
return;
|
|
14436
|
+
}
|
|
14437
|
+
if (measureFrameRef.current !== null) return;
|
|
14438
|
+
measureFrameRef.current = window.requestAnimationFrame(() => {
|
|
14439
|
+
measureFrameRef.current = null;
|
|
14440
|
+
runMeasure();
|
|
14441
|
+
});
|
|
14442
|
+
}, [runMeasure]);
|
|
14443
|
+
useEffect(
|
|
14444
|
+
() => () => {
|
|
14445
|
+
if (measureFrameRef.current !== null && typeof window !== "undefined") {
|
|
14446
|
+
window.cancelAnimationFrame(measureFrameRef.current);
|
|
14447
|
+
measureFrameRef.current = null;
|
|
14448
|
+
}
|
|
14449
|
+
},
|
|
14450
|
+
[]
|
|
14451
|
+
);
|
|
14452
|
+
const getPositionContentRef = useCallback(
|
|
14453
|
+
(id) => {
|
|
14454
|
+
const cachedRef = refCallbacksRef.current.get(id);
|
|
14455
|
+
if (cachedRef) return cachedRef;
|
|
14456
|
+
const refCallback = (node) => {
|
|
14457
|
+
const current = elementsRef.current.get(id);
|
|
14458
|
+
if (current && observerRef.current) {
|
|
14459
|
+
observerRef.current.unobserve(current);
|
|
14460
|
+
}
|
|
14461
|
+
if (node) {
|
|
14462
|
+
elementsRef.current.set(id, node);
|
|
14463
|
+
observerRef.current?.observe(node);
|
|
14464
|
+
} else {
|
|
14465
|
+
elementsRef.current.delete(id);
|
|
14466
|
+
}
|
|
14467
|
+
measure();
|
|
14468
|
+
};
|
|
14469
|
+
refCallbacksRef.current.set(id, refCallback);
|
|
14470
|
+
return refCallback;
|
|
14471
|
+
},
|
|
14472
|
+
[measure]
|
|
14473
|
+
);
|
|
14474
|
+
useEffect(() => {
|
|
14475
|
+
const liveIds = new Set(positionIds);
|
|
14476
|
+
for (const id of elementsRef.current.keys()) {
|
|
14477
|
+
if (!liveIds.has(id)) {
|
|
14478
|
+
elementsRef.current.delete(id);
|
|
14479
|
+
}
|
|
14480
|
+
}
|
|
14481
|
+
for (const id of refCallbacksRef.current.keys()) {
|
|
14482
|
+
if (!liveIds.has(id)) {
|
|
14483
|
+
refCallbacksRef.current.delete(id);
|
|
14484
|
+
}
|
|
14485
|
+
}
|
|
14486
|
+
measure();
|
|
14487
|
+
}, [idsKey, measure]);
|
|
14488
|
+
useIsomorphicLayoutEffect(() => {
|
|
14489
|
+
if (typeof ResizeObserver === "undefined") {
|
|
14490
|
+
measure();
|
|
14491
|
+
return void 0;
|
|
14492
|
+
}
|
|
14493
|
+
const observer = new ResizeObserver(measure);
|
|
14494
|
+
observerRef.current = observer;
|
|
14495
|
+
for (const element of elementsRef.current.values()) {
|
|
14496
|
+
observer.observe(element);
|
|
14497
|
+
}
|
|
14498
|
+
measure();
|
|
14499
|
+
return () => {
|
|
14500
|
+
observer.disconnect();
|
|
14501
|
+
observerRef.current = null;
|
|
14502
|
+
};
|
|
14503
|
+
}, [idsKey, measure]);
|
|
14504
|
+
return {
|
|
14505
|
+
positionContentHeight: heightPx === null ? void 0 : `${heightPx}px`,
|
|
14506
|
+
getPositionContentRef
|
|
14507
|
+
};
|
|
14508
|
+
}
|
|
13632
14509
|
const NumberLineRenderer = ({
|
|
13633
14510
|
item,
|
|
13634
14511
|
widgets,
|
|
@@ -13683,17 +14560,26 @@ const NumberLineRenderer = ({
|
|
|
13683
14560
|
() => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
|
|
13684
14561
|
[isDragMode, positions]
|
|
13685
14562
|
);
|
|
14563
|
+
const visiblePositionIds = useMemo(
|
|
14564
|
+
() => visiblePositions.map((pos) => pos.id),
|
|
14565
|
+
[visiblePositions]
|
|
14566
|
+
);
|
|
14567
|
+
const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
|
|
13686
14568
|
return /* @__PURE__ */ jsxs(
|
|
13687
14569
|
"div",
|
|
13688
14570
|
{
|
|
13689
14571
|
className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
|
|
13690
|
-
style: {
|
|
14572
|
+
style: {
|
|
14573
|
+
[CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
|
|
14574
|
+
...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
|
|
14575
|
+
},
|
|
13691
14576
|
role: "group",
|
|
13692
14577
|
"aria-label": `Number line from ${min} to ${max}`,
|
|
13693
14578
|
children: [
|
|
13694
14579
|
visiblePositions.length > 0 && /* @__PURE__ */ jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsx(
|
|
13695
14580
|
"div",
|
|
13696
14581
|
{
|
|
14582
|
+
ref: getPositionContentRef(pos.id),
|
|
13697
14583
|
className: "number-line-position-content",
|
|
13698
14584
|
style: { left: `${toPercent(pos.value, min, max)}%` },
|
|
13699
14585
|
children: pos.content.map((subItem) => /* @__PURE__ */ jsx(
|
|
@@ -13896,7 +14782,7 @@ const LayoutRenderer = ({
|
|
|
13896
14782
|
if (!widget) return null;
|
|
13897
14783
|
const isCorrect = validationDetails?.[item.widgetId];
|
|
13898
14784
|
const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
|
|
13899
|
-
const widgetKey = widget.type === JSON_TYPES.MATCHER ? `${item.widgetId}-${resetKey}` : item.widgetId;
|
|
14785
|
+
const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
|
|
13900
14786
|
return /* @__PURE__ */ jsx(
|
|
13901
14787
|
WidgetRenderer,
|
|
13902
14788
|
{
|
|
@@ -14301,9 +15187,14 @@ export {
|
|
|
14301
15187
|
TextItemSchema,
|
|
14302
15188
|
ToolNodeSchema,
|
|
14303
15189
|
TransformError,
|
|
15190
|
+
VISUAL_FRACTION_DEFAULT_SIZE,
|
|
15191
|
+
VISUAL_FRACTION_MAX_DIVISIONS,
|
|
15192
|
+
VISUAL_FRACTION_SHAPE,
|
|
15193
|
+
VISUAL_FRACTION_SHAPES,
|
|
14304
15194
|
ValidationError,
|
|
14305
15195
|
ValidationJSONSchema,
|
|
14306
15196
|
ValidationNodeSchema,
|
|
15197
|
+
VisualFractionInlineSchema,
|
|
14307
15198
|
WidgetJSONSchema,
|
|
14308
15199
|
WidgetRefSchema,
|
|
14309
15200
|
WidgetRenderer,
|
|
@@ -14328,6 +15219,7 @@ export {
|
|
|
14328
15219
|
createSpace,
|
|
14329
15220
|
createStringInput,
|
|
14330
15221
|
createTextBlock,
|
|
15222
|
+
createVisualFraction,
|
|
14331
15223
|
evaluate,
|
|
14332
15224
|
extractVariables as extractExpressionVariables,
|
|
14333
15225
|
findValidCombination,
|
|
@@ -14371,6 +15263,7 @@ export {
|
|
|
14371
15263
|
isToolNode,
|
|
14372
15264
|
isValidExpression,
|
|
14373
15265
|
isValidationNode,
|
|
15266
|
+
isVisualFractionInline,
|
|
14374
15267
|
isWidgetRef,
|
|
14375
15268
|
parse,
|
|
14376
15269
|
parseFractionKey,
|
|
@@ -14387,6 +15280,7 @@ export {
|
|
|
14387
15280
|
validateExpression,
|
|
14388
15281
|
validateInputFormats,
|
|
14389
15282
|
validateSimple,
|
|
15283
|
+
validateVisualFractionCapacity,
|
|
14390
15284
|
validateWidgetJSON,
|
|
14391
15285
|
validateWidgetJSONSafe
|
|
14392
15286
|
};
|