@mathscan/math-exercise-engine 1.2.2 → 1.2.3

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.js CHANGED
@@ -79,6 +79,25 @@ const AST_TYPES = {
79
79
  /** Visual fraction inline widget */
80
80
  VISUAL_FRACTION: "VisualFraction",
81
81
  // ═══════════════════════════════════════════════════════════════════════════
82
+ // Chart
83
+ // ═══════════════════════════════════════════════════════════════════════════
84
+ /** Generic chart layout block (bar, pictograph, …) */
85
+ CHART_BLOCK: "ChartBlock",
86
+ /**
87
+ * X axis sub-block: when present the Bar label inlines are hidden.
88
+ * If both X_AXIS and Y_AXIS exist, X_AXIS acts as category axis.
89
+ */
90
+ CHART_X_AXIS: "ChartXAxis",
91
+ /**
92
+ * Y axis sub-block: value axis for vertical bar charts.
93
+ * If only X_AXIS is present, the transformer uses a default Y value axis.
94
+ */
95
+ CHART_Y_AXIS: "ChartYAxis",
96
+ /** A single bar inside a CHART[type="bar"] block */
97
+ CHART_BAR: "ChartBar",
98
+ /** A single pictograph item inside a CHART[type="pictograph"] block */
99
+ CHART_PICTO: "ChartPicto",
100
+ // ═══════════════════════════════════════════════════════════════════════════
82
101
  // Tools
83
102
  // ═══════════════════════════════════════════════════════════════════════════
84
103
  /** Drag-and-drop options tool */
@@ -173,7 +192,14 @@ const JSON_TYPES = {
173
192
  /** Pair-matcher widget (top-row ↔ bottom-row pairing) */
174
193
  PAIR_MATCHER: "pair-matcher",
175
194
  // ═══════════════════════════════════════════════════════════════════════════
176
- // Widget Types
195
+ // Chart
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+ /** Generic chart block (bar, pictograph, …) */
198
+ CHART: "chart",
199
+ /** A single bar rendered inside a chart */
200
+ CHART_BAR: "chart-bar",
201
+ /** A single pictograph item rendered inside a chart */
202
+ CHART_PICTO: "chart-picto",
177
203
  // ═══════════════════════════════════════════════════════════════════════════
178
204
  /** Inline input widget (string or numeric) */
179
205
  INLINE_INPUT: "inline-input",
@@ -426,6 +452,26 @@ const RULER = chevrotain.createToken({
426
452
  name: "RULER",
427
453
  pattern: /RULER/
428
454
  });
455
+ const CHART = chevrotain.createToken({
456
+ name: "CHART",
457
+ pattern: /CHART/
458
+ });
459
+ const X_AXIS = chevrotain.createToken({
460
+ name: "X_AXIS",
461
+ pattern: /X_AXIS/
462
+ });
463
+ const Y_AXIS = chevrotain.createToken({
464
+ name: "Y_AXIS",
465
+ pattern: /Y_AXIS/
466
+ });
467
+ const BAR = chevrotain.createToken({
468
+ name: "BAR",
469
+ pattern: /BAR/
470
+ });
471
+ const PICTO = chevrotain.createToken({
472
+ name: "PICTO",
473
+ pattern: /PICTO/
474
+ });
429
475
  const SEP = chevrotain.createToken({
430
476
  name: "SEP",
431
477
  pattern: /SEP(?![a-zA-Z0-9_])/
@@ -568,6 +614,11 @@ const allTokens = [
568
614
  PROTRACTOR,
569
615
  SET_SQUARE,
570
616
  RULER,
617
+ CHART,
618
+ X_AXIS,
619
+ Y_AXIS,
620
+ BAR,
621
+ PICTO,
571
622
  SEP,
572
623
  CARRY,
573
624
  INPUT,
@@ -737,6 +788,10 @@ class MathExerciseParser extends chevrotain.CstParser {
737
788
  GATE: this.BACKTRACK(this.verticalOperation),
738
789
  ALT: () => this.SUBRULE(this.verticalOperation)
739
790
  },
791
+ {
792
+ GATE: this.BACKTRACK(this.chartBlock),
793
+ ALT: () => this.SUBRULE(this.chartBlock)
794
+ },
740
795
  { ALT: () => this.SUBRULE(this.line) }
741
796
  ]);
742
797
  });
@@ -1592,6 +1647,168 @@ class MathExerciseParser extends chevrotain.CstParser {
1592
1647
  this.MANY4(() => this.CONSUME4(Space));
1593
1648
  this.CONSUME(RBracket);
1594
1649
  });
1650
+ this.chartBlock = this.RULE("chartBlock", () => {
1651
+ this.MANY(() => {
1652
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1653
+ });
1654
+ this.CONSUME(CHART);
1655
+ this.CONSUME(LBracket);
1656
+ this.SUBRULE(this.parameterList);
1657
+ this.CONSUME(RBracket);
1658
+ this.MANY2(() => {
1659
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1660
+ });
1661
+ this.CONSUME(LBrace);
1662
+ this.MANY3(() => {
1663
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1664
+ });
1665
+ this.MANY5({
1666
+ GATE: () => this.LA(1).tokenType === X_AXIS || this.LA(1).tokenType === Y_AXIS,
1667
+ DEF: () => {
1668
+ this.OR5([
1669
+ { ALT: () => this.SUBRULE(this.chartXAxis) },
1670
+ { ALT: () => this.SUBRULE(this.chartYAxis) }
1671
+ ]);
1672
+ }
1673
+ });
1674
+ this.MANY6({
1675
+ GATE: () => {
1676
+ let k = 1;
1677
+ while (this.LA(k).tokenType === Space || this.LA(k).tokenType === Newline) k++;
1678
+ const t = this.LA(k).tokenType;
1679
+ return t === BAR || t === PICTO;
1680
+ },
1681
+ DEF: () => {
1682
+ this.OR6([
1683
+ {
1684
+ GATE: () => {
1685
+ let k = 1;
1686
+ while (this.LA(k).tokenType === Space || this.LA(k).tokenType === Newline) k++;
1687
+ return this.LA(k).tokenType === BAR;
1688
+ },
1689
+ ALT: () => this.SUBRULE(this.chartBarItem)
1690
+ },
1691
+ { ALT: () => this.SUBRULE(this.chartPictoItem) }
1692
+ ]);
1693
+ }
1694
+ });
1695
+ this.CONSUME(RBrace);
1696
+ this.MANY4(() => {
1697
+ this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
1698
+ });
1699
+ });
1700
+ this.chartXAxis = this.RULE("chartXAxis", () => {
1701
+ this.CONSUME(X_AXIS);
1702
+ this.OPTION(() => {
1703
+ this.CONSUME(LBracket);
1704
+ this.OPTION2(() => {
1705
+ this.SUBRULE(this.parameterList);
1706
+ });
1707
+ this.CONSUME(RBracket);
1708
+ });
1709
+ this.MANY(() => {
1710
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1711
+ });
1712
+ this.OPTION3(() => {
1713
+ this.CONSUME(LBrace);
1714
+ this.MANY2(() => {
1715
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1716
+ });
1717
+ this.MANY3(() => {
1718
+ this.OR3([
1719
+ { ALT: () => this.SUBRULE(this.inlineElement) },
1720
+ { ALT: () => this.CONSUME3(Newline) }
1721
+ ]);
1722
+ });
1723
+ this.CONSUME(RBrace);
1724
+ });
1725
+ this.MANY4(() => {
1726
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1727
+ });
1728
+ });
1729
+ this.chartYAxis = this.RULE("chartYAxis", () => {
1730
+ this.CONSUME(Y_AXIS);
1731
+ this.OPTION(() => {
1732
+ this.CONSUME(LBracket);
1733
+ this.OPTION2(() => {
1734
+ this.SUBRULE(this.parameterList);
1735
+ });
1736
+ this.CONSUME(RBracket);
1737
+ });
1738
+ this.MANY(() => {
1739
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1740
+ });
1741
+ this.OPTION3(() => {
1742
+ this.CONSUME(LBrace);
1743
+ this.MANY2(() => {
1744
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1745
+ });
1746
+ this.MANY3(() => {
1747
+ this.OR3([
1748
+ { ALT: () => this.SUBRULE(this.inlineElement) },
1749
+ { ALT: () => this.CONSUME3(Newline) }
1750
+ ]);
1751
+ });
1752
+ this.CONSUME(RBrace);
1753
+ });
1754
+ this.MANY4(() => {
1755
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1756
+ });
1757
+ });
1758
+ this.chartBarItem = this.RULE("chartBarItem", () => {
1759
+ this.MANY(() => {
1760
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1761
+ });
1762
+ this.CONSUME(BAR);
1763
+ this.CONSUME(LBracket);
1764
+ this.SUBRULE(this.parameterList);
1765
+ this.CONSUME(RBracket);
1766
+ this.MANY2(() => {
1767
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1768
+ });
1769
+ this.OPTION(() => {
1770
+ this.CONSUME(LBrace);
1771
+ this.MANY3(() => {
1772
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1773
+ });
1774
+ this.MANY4(() => {
1775
+ this.OR4([
1776
+ { ALT: () => this.SUBRULE(this.inlineElement) },
1777
+ { ALT: () => this.CONSUME4(Newline) }
1778
+ ]);
1779
+ });
1780
+ this.CONSUME(RBrace);
1781
+ });
1782
+ this.MANY5(() => {
1783
+ this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
1784
+ });
1785
+ });
1786
+ this.chartPictoItem = this.RULE("chartPictoItem", () => {
1787
+ this.MANY(() => {
1788
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1789
+ });
1790
+ this.CONSUME(PICTO);
1791
+ this.CONSUME(LBracket);
1792
+ this.SUBRULE(this.parameterList);
1793
+ this.CONSUME(RBracket);
1794
+ this.MANY2(() => {
1795
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1796
+ });
1797
+ this.CONSUME(LBrace);
1798
+ this.MANY3(() => {
1799
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1800
+ });
1801
+ this.MANY4(() => {
1802
+ this.OR4([
1803
+ { ALT: () => this.SUBRULE(this.inlineElement) },
1804
+ { ALT: () => this.CONSUME4(Newline) }
1805
+ ]);
1806
+ });
1807
+ this.CONSUME(RBrace);
1808
+ this.MANY5(() => {
1809
+ this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
1810
+ });
1811
+ });
1595
1812
  this.performSelfAnalysis();
1596
1813
  }
1597
1814
  /** Distinguish `{[number, number], ...}` and the valid empty form `{}` from string lists. */
@@ -1606,6 +1823,18 @@ class MathExerciseParser extends chevrotain.CstParser {
1606
1823
  }
1607
1824
  }
1608
1825
  const parser = new MathExerciseParser();
1826
+ function normalize(value) {
1827
+ if (typeof value === "number") {
1828
+ return String(Math.round(value * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
1829
+ }
1830
+ const trimmed = String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
1831
+ const asNum = Number(trimmed);
1832
+ if (trimmed !== "" && !isNaN(asNum)) {
1833
+ return String(Math.round(asNum * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
1834
+ }
1835
+ return trimmed;
1836
+ }
1837
+ const IMAGE_SRC_SCHEME_RE = /^(https?:\/\/|data:image\/)/i;
1609
1838
  function parseOrThrow(schema, data, label) {
1610
1839
  const result = schema.safeParse(data);
1611
1840
  if (!result.success) {
@@ -1675,11 +1904,10 @@ const FractionInlineSchema = zod.z.object({
1675
1904
  simplified: zod.z.boolean().default(true),
1676
1905
  placeholder: zod.z.string().max(32).optional()
1677
1906
  });
1678
- const IMAGE_SRC_SCHEME_RE$1 = /^(https?:\/\/|data:image\/)/i;
1679
1907
  const ImageInlineSchema = zod.z.object({
1680
1908
  type: zod.z.literal(AST_TYPES.IMAGE),
1681
1909
  name: zod.z.string().min(1),
1682
- src: zod.z.string().url().refine((s) => IMAGE_SRC_SCHEME_RE$1.test(s), {
1910
+ src: zod.z.string().url().refine((s) => IMAGE_SRC_SCHEME_RE.test(s), {
1683
1911
  message: "src must use http(s) or data:image scheme"
1684
1912
  }),
1685
1913
  alt: zod.z.string(),
@@ -2721,6 +2949,350 @@ function isSetSquareTool(value) {
2721
2949
  function isToolNode(value) {
2722
2950
  return ToolNodeSchema.safeParse(value).success;
2723
2951
  }
2952
+ const CHART_TYPES = ["bar", "pictograph"];
2953
+ const CHART_AXIS_DEFAULTS = { min: 0, max: 100, step: 10, visible: true };
2954
+ const CHART_MAX_AXIS_TICKS = 200;
2955
+ const CHART_MAX_PICTO_SLOTS = 60;
2956
+ const ChartAxisSchema = zod.z.object({
2957
+ type: zod.z.literal(AST_TYPES.CHART_X_AXIS).or(zod.z.literal(AST_TYPES.CHART_Y_AXIS)),
2958
+ min: zod.z.number().optional(),
2959
+ max: zod.z.number().optional(),
2960
+ step: zod.z.number().positive().optional(),
2961
+ /** When false, the entire axis is hidden. Defaults to true. */
2962
+ visible: zod.z.boolean().optional(),
2963
+ /** Inline elements used as the axis title label */
2964
+ inlines: zod.z.array(InlineNodeSchema).default([])
2965
+ }).superRefine((axis, ctx) => {
2966
+ if (axis.min !== void 0 && axis.max !== void 0 && axis.min >= axis.max) {
2967
+ ctx.addIssue({
2968
+ code: zod.z.ZodIssueCode.custom,
2969
+ message: `AXIS: min (${axis.min}) must be strictly less than max (${axis.max})`,
2970
+ path: ["min"]
2971
+ });
2972
+ }
2973
+ });
2974
+ function createChartAxis(axisType, opts) {
2975
+ return ChartAxisSchema.parse({
2976
+ type: axisType,
2977
+ min: opts.min,
2978
+ max: opts.max,
2979
+ step: opts.step,
2980
+ visible: opts.visible,
2981
+ inlines: opts.inlines ?? []
2982
+ });
2983
+ }
2984
+ function isChartAxis(value) {
2985
+ return ChartAxisSchema.safeParse(value).success;
2986
+ }
2987
+ const ChartPictoSchema = zod.z.object({
2988
+ type: zod.z.literal(AST_TYPES.CHART_PICTO),
2989
+ id: zod.z.string().min(1),
2990
+ value: zod.z.number().default(0),
2991
+ answer: zod.z.number().optional(),
2992
+ image: zod.z.string().min(1).refine(
2993
+ (s) => IMAGE_SRC_SCHEME_RE.test(s),
2994
+ { message: "image must use http(s) or data:image scheme" }
2995
+ ),
2996
+ inlines: zod.z.array(InlineNodeSchema).default([])
2997
+ });
2998
+ function createChartPicto(id, opts) {
2999
+ return ChartPictoSchema.parse({
3000
+ type: AST_TYPES.CHART_PICTO,
3001
+ id,
3002
+ value: opts.value ?? 0,
3003
+ answer: opts.answer,
3004
+ image: opts.image,
3005
+ inlines: opts.inlines ?? []
3006
+ });
3007
+ }
3008
+ function isChartPicto(value) {
3009
+ return ChartPictoSchema.safeParse(value).success;
3010
+ }
3011
+ const ChartBarSchema = zod.z.object({
3012
+ type: zod.z.literal(AST_TYPES.CHART_BAR),
3013
+ id: zod.z.string().min(1),
3014
+ value: zod.z.number().default(0),
3015
+ answer: zod.z.number().optional(),
3016
+ color: zod.z.string().optional(),
3017
+ inlines: zod.z.array(InlineNodeSchema).default([])
3018
+ });
3019
+ function createChartBar(id, opts) {
3020
+ return ChartBarSchema.parse({
3021
+ type: AST_TYPES.CHART_BAR,
3022
+ id,
3023
+ value: opts.value ?? 0,
3024
+ answer: opts.answer,
3025
+ color: opts.color,
3026
+ inlines: opts.inlines ?? []
3027
+ });
3028
+ }
3029
+ function isChartBar(value) {
3030
+ return ChartBarSchema.safeParse(value).success;
3031
+ }
3032
+ const ChartBlockSchema = zod.z.object({
3033
+ type: zod.z.literal(AST_TYPES.CHART_BLOCK),
3034
+ name: zod.z.string().optional(),
3035
+ title: zod.z.string().optional(),
3036
+ chartType: zod.z.enum(CHART_TYPES),
3037
+ grid: zod.z.boolean().default(false),
3038
+ scale: zod.z.number().positive().default(1),
3039
+ snap: zod.z.number().positive().optional(),
3040
+ xAxis: ChartAxisSchema.optional(),
3041
+ yAxis: ChartAxisSchema.optional(),
3042
+ bars: zod.z.array(ChartBarSchema).default([]),
3043
+ pictos: zod.z.array(ChartPictoSchema).default([])
3044
+ }).superRefine((block, ctx) => {
3045
+ validateChartBlock(block, ctx);
3046
+ });
3047
+ function resolveChartAxes(block) {
3048
+ const horizontal = axisHasNumericParams(block.xAxis) && !axisHasNumericParams(block.yAxis);
3049
+ const orientation = horizontal ? "horizontal" : "vertical";
3050
+ return {
3051
+ orientation,
3052
+ valueAxis: resolveNumericAxis(horizontal ? block.xAxis : block.yAxis),
3053
+ categoryAxis: horizontal ? block.yAxis : block.xAxis
3054
+ };
3055
+ }
3056
+ function validateChartBlock(block, ctx) {
3057
+ if (hasAmbiguousValueAxes(block)) {
3058
+ ctx.addIssue({
3059
+ code: zod.z.ZodIssueCode.custom,
3060
+ message: `CHART[type="${block.chartType}"]: both X_AXIS and Y_AXIS declare numeric params (min/max/step) — ambiguous orientation. Remove params from one axis: the axis with params becomes the value axis.`,
3061
+ path: ["xAxis"]
3062
+ });
3063
+ return;
3064
+ }
3065
+ const { valueAxis } = resolveChartAxes(block);
3066
+ validateValueAxisRange(valueAxis, ctx);
3067
+ if (block.chartType === "bar") {
3068
+ if (block.pictos.length > 0) {
3069
+ ctx.addIssue({
3070
+ code: zod.z.ZodIssueCode.custom,
3071
+ message: 'CHART[type="bar"]: body must contain BAR items only, not PICTO',
3072
+ path: ["pictos"]
3073
+ });
3074
+ return;
3075
+ }
3076
+ if (block.bars.length === 0) {
3077
+ ctx.addIssue({
3078
+ code: zod.z.ZodIssueCode.custom,
3079
+ message: 'CHART[type="bar"]: at least one BAR is required',
3080
+ path: ["bars"]
3081
+ });
3082
+ return;
3083
+ }
3084
+ validateUniqueIds(block.bars, ctx);
3085
+ validateChartSnap(block.snap, valueAxis.step, ctx);
3086
+ validateBarAnswers(block.bars, valueAxis, block.snap, ctx);
3087
+ return;
3088
+ }
3089
+ if (block.chartType === "pictograph") {
3090
+ if (block.bars.length > 0) {
3091
+ ctx.addIssue({
3092
+ code: zod.z.ZodIssueCode.custom,
3093
+ message: 'CHART[type="pictograph"]: body must contain PICTO items only, not BAR',
3094
+ path: ["bars"]
3095
+ });
3096
+ return;
3097
+ }
3098
+ if (block.pictos.length === 0) {
3099
+ ctx.addIssue({
3100
+ code: zod.z.ZodIssueCode.custom,
3101
+ message: 'CHART[type="pictograph"]: at least one PICTO is required',
3102
+ path: ["pictos"]
3103
+ });
3104
+ return;
3105
+ }
3106
+ if (block.snap !== void 0) {
3107
+ ctx.addIssue({
3108
+ code: zod.z.ZodIssueCode.custom,
3109
+ message: 'CHART[type="pictograph"]: snap is not supported for pictograph charts',
3110
+ path: ["snap"]
3111
+ });
3112
+ return;
3113
+ }
3114
+ validateUniqueIds(block.pictos, ctx);
3115
+ if (!axisHasNumericParams(valueAxis.axis)) {
3116
+ ctx.addIssue({
3117
+ code: zod.z.ZodIssueCode.custom,
3118
+ message: 'CHART[type="pictograph"]: a value axis with min/max/step is required (e.g. Y_AXIS[min=0, max=8, step=2]) — without it the chart defaults to a 0–100 range and renders 100 icons per column.',
3119
+ path: ["yAxis"]
3120
+ });
3121
+ return;
3122
+ }
3123
+ const slotCount = Math.round(valueAxis.max - valueAxis.min);
3124
+ if (slotCount > CHART_MAX_PICTO_SLOTS) {
3125
+ ctx.addIssue({
3126
+ code: zod.z.ZodIssueCode.custom,
3127
+ message: `CHART[type="pictograph"]: the value axis spans ${slotCount} units, so each column would render ${slotCount} icons — over the ${CHART_MAX_PICTO_SLOTS} limit. Narrow the range.`,
3128
+ path: ["yAxis"]
3129
+ });
3130
+ return;
3131
+ }
3132
+ validatePictoAnswers(block.pictos, valueAxis, ctx);
3133
+ return;
3134
+ }
3135
+ ctx.addIssue({
3136
+ code: zod.z.ZodIssueCode.custom,
3137
+ message: `CHART: unsupported chartType "${block.chartType}" — add a validation branch to validateChartBlock`,
3138
+ path: ["chartType"]
3139
+ });
3140
+ }
3141
+ function axisHasNumericParams(axis) {
3142
+ return axis?.min !== void 0 || axis?.max !== void 0 || axis?.step !== void 0;
3143
+ }
3144
+ function resolveNumericAxis(axis) {
3145
+ return {
3146
+ axis,
3147
+ min: axis?.min ?? CHART_AXIS_DEFAULTS.min,
3148
+ max: axis?.max ?? CHART_AXIS_DEFAULTS.max,
3149
+ step: axis?.step ?? CHART_AXIS_DEFAULTS.step
3150
+ };
3151
+ }
3152
+ function hasAmbiguousValueAxes(block) {
3153
+ return axisHasNumericParams(block.xAxis) && axisHasNumericParams(block.yAxis);
3154
+ }
3155
+ function validateUniqueIds(items, ctx) {
3156
+ const seen = /* @__PURE__ */ new Set();
3157
+ items.forEach((item, index) => {
3158
+ if (seen.has(item.id)) {
3159
+ ctx.addIssue({
3160
+ code: zod.z.ZodIssueCode.custom,
3161
+ message: `CHART: duplicate id "${item.id}" — ids must be unique within the chart`,
3162
+ path: [index, "id"]
3163
+ });
3164
+ return;
3165
+ }
3166
+ seen.add(item.id);
3167
+ });
3168
+ }
3169
+ function validateValueAxisRange(axis, ctx) {
3170
+ if (axis.min >= axis.max) {
3171
+ ctx.addIssue({
3172
+ code: zod.z.ZodIssueCode.custom,
3173
+ message: `CHART: value axis min (${axis.min}) must be strictly less than max (${axis.max})`
3174
+ });
3175
+ return;
3176
+ }
3177
+ const tickCount = Math.floor((axis.max - axis.min) / axis.step) + 1;
3178
+ if (tickCount > CHART_MAX_AXIS_TICKS) {
3179
+ ctx.addIssue({
3180
+ code: zod.z.ZodIssueCode.custom,
3181
+ message: `CHART: value axis would render ${tickCount} ticks ((${axis.max} − ${axis.min}) / step ${axis.step}), over the ${CHART_MAX_AXIS_TICKS} limit — widen the step or narrow the range.`
3182
+ });
3183
+ }
3184
+ }
3185
+ function validateChartSnap(snap, step, ctx) {
3186
+ if (snap === void 0) return;
3187
+ if (snap > step) {
3188
+ ctx.addIssue({
3189
+ code: zod.z.ZodIssueCode.custom,
3190
+ message: `CHART: snap (${snap}) must be ≤ value-axis step (${step}).`,
3191
+ path: ["snap"]
3192
+ });
3193
+ return;
3194
+ }
3195
+ const remainder = step % snap;
3196
+ if (remainder > 1e-9 && remainder < snap - 1e-9) {
3197
+ ctx.addIssue({
3198
+ code: zod.z.ZodIssueCode.custom,
3199
+ message: `CHART: snap (${snap}) must evenly divide the value-axis step (${step}) — otherwise bars snap to positions between tick marks.`,
3200
+ path: ["snap"]
3201
+ });
3202
+ }
3203
+ }
3204
+ function validateBarAnswers(bars, valueAxis, snap, ctx) {
3205
+ const gridStep = snap ?? valueAxis.step;
3206
+ bars.forEach((bar, index) => {
3207
+ if (bar.value < valueAxis.min || bar.value > valueAxis.max) {
3208
+ ctx.addIssue({
3209
+ code: zod.z.ZodIssueCode.custom,
3210
+ message: `CHART: BAR "${bar.id}" value (${bar.value}) is outside axis range [${valueAxis.min}, ${valueAxis.max}] — the bar will not render correctly`,
3211
+ path: ["bars", index, "value"]
3212
+ });
3213
+ }
3214
+ if (bar.answer === void 0) return;
3215
+ if (bar.answer < valueAxis.min || bar.answer > valueAxis.max) {
3216
+ ctx.addIssue({
3217
+ code: zod.z.ZodIssueCode.custom,
3218
+ message: `CHART: BAR "${bar.id}" answer (${bar.answer}) is outside axis range [${valueAxis.min}, ${valueAxis.max}] — the student can never reach this value`,
3219
+ path: ["bars", index, "answer"]
3220
+ });
3221
+ return;
3222
+ }
3223
+ const offGrid = Math.abs(bar.answer - valueAxis.min) % gridStep;
3224
+ if (offGrid > 1e-9 && offGrid < gridStep - 1e-9) {
3225
+ ctx.addIssue({
3226
+ code: zod.z.ZodIssueCode.custom,
3227
+ message: `CHART: BAR "${bar.id}" answer (${bar.answer}) is not on the drag grid (min ${valueAxis.min} + multiples of ${gridStep}) — the student can never land on it. Align the answer to the step/snap grid.`,
3228
+ path: ["bars", index, "answer"]
3229
+ });
3230
+ }
3231
+ });
3232
+ }
3233
+ function isOnPictoGrid(value, axisMin) {
3234
+ const offset = value - axisMin;
3235
+ return Math.abs(offset - Math.round(offset)) <= 1e-9;
3236
+ }
3237
+ function validatePictoAnswers(pictos, valueAxis, ctx) {
3238
+ pictos.forEach((picto, index) => {
3239
+ if (picto.value < valueAxis.min || picto.value > valueAxis.max) {
3240
+ ctx.addIssue({
3241
+ code: zod.z.ZodIssueCode.custom,
3242
+ message: `CHART: PICTO "${picto.id}" value (${picto.value}) is outside axis range [${valueAxis.min}, ${valueAxis.max}]`,
3243
+ path: ["pictos", index, "value"]
3244
+ });
3245
+ } else if (!isOnPictoGrid(picto.value, valueAxis.min)) {
3246
+ ctx.addIssue({
3247
+ code: zod.z.ZodIssueCode.custom,
3248
+ message: `CHART: PICTO "${picto.id}" value (${picto.value}) is not a whole number of icons above the axis minimum (${valueAxis.min}) — icons can't be drawn in fractions.`,
3249
+ path: ["pictos", index, "value"]
3250
+ });
3251
+ }
3252
+ if (picto.answer === void 0) return;
3253
+ if (picto.answer < valueAxis.min || picto.answer > valueAxis.max) {
3254
+ ctx.addIssue({
3255
+ code: zod.z.ZodIssueCode.custom,
3256
+ message: `CHART: PICTO "${picto.id}" answer (${picto.answer}) is outside axis range [${valueAxis.min}, ${valueAxis.max}] — the student can never reach this value`,
3257
+ path: ["pictos", index, "answer"]
3258
+ });
3259
+ return;
3260
+ }
3261
+ if (!isOnPictoGrid(picto.answer, valueAxis.min)) {
3262
+ ctx.addIssue({
3263
+ code: zod.z.ZodIssueCode.custom,
3264
+ message: `CHART: PICTO "${picto.id}" answer (${picto.answer}) is not a whole number of icons above the axis minimum (${valueAxis.min}) — the student can never land on it.`,
3265
+ path: ["pictos", index, "answer"]
3266
+ });
3267
+ return;
3268
+ }
3269
+ if (picto.answer < picto.value) {
3270
+ ctx.addIssue({
3271
+ code: zod.z.ZodIssueCode.custom,
3272
+ message: `CHART: PICTO "${picto.id}" answer (${picto.answer}) is below its prefilled value (${picto.value}) — prefilled icons can't be removed, so the exercise is unsolvable.`,
3273
+ path: ["pictos", index, "answer"]
3274
+ });
3275
+ }
3276
+ });
3277
+ }
3278
+ function createChartBlock(chartType, opts = {}) {
3279
+ return ChartBlockSchema.parse({
3280
+ type: AST_TYPES.CHART_BLOCK,
3281
+ name: opts.name,
3282
+ title: opts.title,
3283
+ chartType,
3284
+ grid: opts.grid ?? false,
3285
+ scale: opts.scale ?? 1,
3286
+ snap: opts.snap,
3287
+ xAxis: opts.xAxis,
3288
+ yAxis: opts.yAxis,
3289
+ bars: opts.bars ?? [],
3290
+ pictos: opts.pictos ?? []
3291
+ });
3292
+ }
3293
+ function isChartBlock(value) {
3294
+ return ChartBlockSchema.safeParse(value).success;
3295
+ }
2724
3296
  const RadioOptionsBlockSchema = zod.z.object({
2725
3297
  type: zod.z.literal(AST_TYPES.RADIO_OPTIONS),
2726
3298
  name: zod.z.string(),
@@ -2780,7 +3352,7 @@ function isMixedValidation(value) {
2780
3352
  function isValidationNode(value) {
2781
3353
  return ValidationNodeSchema.safeParse(value).success;
2782
3354
  }
2783
- const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema, GridSymmetryBlockSchema]);
3355
+ const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema, GridSymmetryBlockSchema, ChartBlockSchema]);
2784
3356
  const ExerciseDocumentSchema = zod.z.object({
2785
3357
  type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
2786
3358
  question: TextBlockSchema.optional(),
@@ -2886,6 +3458,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2886
3458
  if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
2887
3459
  if (ctx.matcher) return this.visit(ctx.matcher);
2888
3460
  if (ctx.verticalOperation) return this.visit(ctx.verticalOperation);
3461
+ if (ctx.chartBlock) return this.visit(ctx.chartBlock);
2889
3462
  if (ctx.line) return this.visit(ctx.line);
2890
3463
  throw new Error("Unknown content block");
2891
3464
  }
@@ -3364,6 +3937,70 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3364
3937
  return createInlineDragOption(value);
3365
3938
  }
3366
3939
  // ═══════════════════════════════════════════════════════════════════════════
3940
+ // Chart Block
3941
+ // ═══════════════════════════════════════════════════════════════════════════
3942
+ chartBlock(ctx) {
3943
+ const params = this.visit(ctx.parameterList);
3944
+ const chartType = params.type;
3945
+ if (!chartType) throw new Error('CHART: type is required ("bar" | "pictograph")');
3946
+ if (!CHART_TYPES.includes(chartType)) {
3947
+ throw new Error(`CHART: type must be one of ${CHART_TYPES.join(", ")} (got "${chartType}")`);
3948
+ }
3949
+ const name = params.name;
3950
+ const title = params.title;
3951
+ const grid = params.grid === true || params.grid === "true";
3952
+ const scale = params.scale !== void 0 ? Number(params.scale) : 1;
3953
+ const snap = params.snap !== void 0 ? Number(params.snap) : void 0;
3954
+ if (ctx.chartXAxis && ctx.chartXAxis.length > 1) {
3955
+ throw new Error("CHART: only one X_AXIS is allowed");
3956
+ }
3957
+ if (ctx.chartYAxis && ctx.chartYAxis.length > 1) {
3958
+ throw new Error("CHART: only one Y_AXIS is allowed");
3959
+ }
3960
+ const xAxis = ctx.chartXAxis ? this.visit(ctx.chartXAxis) : void 0;
3961
+ const yAxis = ctx.chartYAxis ? this.visit(ctx.chartYAxis) : void 0;
3962
+ const bars = ctx.chartBarItem ? ctx.chartBarItem.map((b) => this.visit(b)) : [];
3963
+ const pictos = ctx.chartPictoItem ? ctx.chartPictoItem.map((p) => this.visit(p)) : [];
3964
+ return createChartBlock(chartType, { bars, pictos, name, title, grid, scale, snap, xAxis, yAxis });
3965
+ }
3966
+ /** X_AXIS and Y_AXIS carry identical params — only the produced AST type differs.
3967
+ * Chevrotain needs two separate grammar rules, but one visitor body serves both. */
3968
+ buildChartAxis(ctx, astType) {
3969
+ const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
3970
+ const min = params.min !== void 0 ? Number(params.min) : void 0;
3971
+ const max = params.max !== void 0 ? Number(params.max) : void 0;
3972
+ const step = params.step !== void 0 ? Number(params.step) : void 0;
3973
+ const visible = params.visible !== void 0 ? params.visible === true || params.visible === "true" : void 0;
3974
+ const inlines = ctx.inlineElement ? ctx.inlineElement.map((e) => this.visit(e)) : [];
3975
+ return createChartAxis(astType, { min, max, step, visible, inlines });
3976
+ }
3977
+ chartXAxis(ctx) {
3978
+ return this.buildChartAxis(ctx, AST_TYPES.CHART_X_AXIS);
3979
+ }
3980
+ chartYAxis(ctx) {
3981
+ return this.buildChartAxis(ctx, AST_TYPES.CHART_Y_AXIS);
3982
+ }
3983
+ chartBarItem(ctx) {
3984
+ const params = this.visit(ctx.parameterList);
3985
+ const inlines = ctx.inlineElement ? ctx.inlineElement.map((e) => this.visit(e)) : [];
3986
+ return createChartBar(params.id, {
3987
+ value: params.value !== void 0 ? Number(params.value) : void 0,
3988
+ answer: params.answer !== void 0 ? Number(params.answer) : void 0,
3989
+ color: params.color,
3990
+ inlines
3991
+ });
3992
+ }
3993
+ chartPictoItem(ctx) {
3994
+ const params = this.visit(ctx.parameterList);
3995
+ const inlines = ctx.inlineElement ? ctx.inlineElement.map((e) => this.visit(e)) : [];
3996
+ return createChartPicto(params.id, {
3997
+ value: params.value !== void 0 ? Number(params.value) : void 0,
3998
+ answer: params.answer !== void 0 ? Number(params.answer) : void 0,
3999
+ image: params.image,
4000
+ inlines
4001
+ });
4002
+ }
4003
+ // ═══════════════════════════════════════════════════════════════════════════
3367
4004
  // Tools
3368
4005
  // ═══════════════════════════════════════════════════════════════════════════
3369
4006
  tool(ctx) {
@@ -3550,7 +4187,6 @@ const BoldTextSchema = zod.z.object({
3550
4187
  value: zod.z.string(),
3551
4188
  highlight: zod.z.tuple([zod.z.number(), zod.z.number()]).optional()
3552
4189
  });
3553
- const IMAGE_SRC_SCHEME_RE = /^(https?:\/\/|data:image\/)/i;
3554
4190
  const ImageItemSchema = zod.z.object({
3555
4191
  id: zod.z.string(),
3556
4192
  type: zod.z.literal(JSON_TYPES.IMAGE),
@@ -3619,7 +4255,8 @@ const LayoutItemSchema = zod.z.lazy(
3619
4255
  TableItemSchema,
3620
4256
  NumberLineItemSchema,
3621
4257
  VerticalOperationItemSchema,
3622
- ClockItemSchema
4258
+ ClockItemSchema,
4259
+ ChartItemSchema
3623
4260
  ])
3624
4261
  );
3625
4262
  const GroupItemSchema = zod.z.object({
@@ -3792,6 +4429,97 @@ const VerticalOperationItemSchema = zod.z.object({
3792
4429
  cols: zod.z.number().int().positive(),
3793
4430
  items: zod.z.array(zod.z.union([VopRowItemSchema, VopCarryRowItemSchema, VopSeparatorItemSchema]))
3794
4431
  });
4432
+ const ChartAxisJSONSchema = zod.z.object({
4433
+ min: zod.z.number().optional(),
4434
+ max: zod.z.number().optional(),
4435
+ step: zod.z.number().positive().optional(),
4436
+ visible: zod.z.boolean().default(true),
4437
+ title: zod.z.array(LayoutItemSchema).optional()
4438
+ }).superRefine((axis, ctx) => {
4439
+ const numericFields = [axis.min, axis.max, axis.step];
4440
+ const numericFieldCount = numericFields.filter((value) => value !== void 0).length;
4441
+ if (numericFieldCount > 0 && numericFieldCount < numericFields.length) {
4442
+ ctx.addIssue({
4443
+ code: zod.z.ZodIssueCode.custom,
4444
+ message: "AXIS: min, max, and step must be provided together for a numeric axis"
4445
+ });
4446
+ return;
4447
+ }
4448
+ if (axis.min !== void 0 && axis.max !== void 0 && axis.min >= axis.max) {
4449
+ ctx.addIssue({
4450
+ code: zod.z.ZodIssueCode.custom,
4451
+ message: `AXIS: min (${axis.min}) must be strictly less than max (${axis.max})`,
4452
+ path: ["min"]
4453
+ });
4454
+ }
4455
+ });
4456
+ const ChartBarJSONSchema = zod.z.discriminatedUnion("interactive", [
4457
+ zod.z.object({
4458
+ id: zod.z.string(),
4459
+ type: zod.z.literal(JSON_TYPES.CHART_BAR),
4460
+ interactive: zod.z.literal(false),
4461
+ value: zod.z.number(),
4462
+ color: zod.z.string().optional(),
4463
+ label: zod.z.array(LayoutItemSchema).optional()
4464
+ }),
4465
+ zod.z.object({
4466
+ id: zod.z.string(),
4467
+ type: zod.z.literal(JSON_TYPES.CHART_BAR),
4468
+ interactive: zod.z.literal(true),
4469
+ value: zod.z.number(),
4470
+ widgetId: zod.z.string(),
4471
+ color: zod.z.string().optional(),
4472
+ label: zod.z.array(LayoutItemSchema).optional()
4473
+ })
4474
+ ]);
4475
+ const ChartPictoJSONSchema = zod.z.object({
4476
+ id: zod.z.string(),
4477
+ type: zod.z.literal(JSON_TYPES.CHART_PICTO),
4478
+ value: zod.z.number(),
4479
+ answer: zod.z.number().optional(),
4480
+ image: zod.z.string().min(1).refine(
4481
+ (s) => IMAGE_SRC_SCHEME_RE.test(s),
4482
+ { message: "image must use http(s) or data:image scheme" }
4483
+ ),
4484
+ label: zod.z.array(LayoutItemSchema).optional()
4485
+ });
4486
+ const ChartItemSchema = zod.z.object({
4487
+ id: zod.z.string(),
4488
+ type: zod.z.literal(JSON_TYPES.CHART),
4489
+ name: zod.z.string().optional(),
4490
+ title: zod.z.string().optional(),
4491
+ chartType: zod.z.enum(CHART_TYPES),
4492
+ orientation: zod.z.enum(["vertical", "horizontal"]).default("vertical"),
4493
+ grid: zod.z.boolean().default(false),
4494
+ scale: zod.z.number().positive().default(1),
4495
+ snap: zod.z.number().positive().optional(),
4496
+ xAxis: ChartAxisJSONSchema.optional(),
4497
+ yAxis: ChartAxisJSONSchema.optional(),
4498
+ bars: zod.z.array(ChartBarJSONSchema).default([]),
4499
+ pictos: zod.z.array(ChartPictoJSONSchema).default([])
4500
+ }).superRefine((item, ctx) => {
4501
+ if (item.chartType === "bar" && item.bars.length === 0) {
4502
+ ctx.addIssue({
4503
+ code: zod.z.ZodIssueCode.custom,
4504
+ message: 'CHART[type="bar"]: at least one BAR is required',
4505
+ path: ["bars"]
4506
+ });
4507
+ }
4508
+ if (item.chartType === "pictograph" && item.pictos.length === 0) {
4509
+ ctx.addIssue({
4510
+ code: zod.z.ZodIssueCode.custom,
4511
+ message: 'CHART[type="pictograph"]: at least one PICTO is required',
4512
+ path: ["pictos"]
4513
+ });
4514
+ }
4515
+ if (item.chartType === "pictograph" && item.snap !== void 0) {
4516
+ ctx.addIssue({
4517
+ code: zod.z.ZodIssueCode.custom,
4518
+ message: 'CHART[type="pictograph"]: snap is not supported for pictograph charts',
4519
+ path: ["snap"]
4520
+ });
4521
+ }
4522
+ });
3795
4523
  const StringInputJSONSchema = zod.z.object({
3796
4524
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
3797
4525
  inputType: zod.z.literal(INPUT_TYPES.STRING),
@@ -3850,6 +4578,14 @@ const PairMatcherWidgetJSONSchema = zod.z.object({
3850
4578
  zod.z.array(MatcherItemContentSchema).min(1)
3851
4579
  ])
3852
4580
  });
4581
+ const ChartBarWidgetJSONSchema = zod.z.object({
4582
+ type: zod.z.literal(JSON_TYPES.CHART_BAR),
4583
+ value: zod.z.number()
4584
+ });
4585
+ const ChartPictoWidgetJSONSchema = zod.z.object({
4586
+ type: zod.z.literal(JSON_TYPES.CHART_PICTO),
4587
+ value: zod.z.number()
4588
+ });
3853
4589
  const VisualFractionJSONSchema = zod.z.object({
3854
4590
  type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
3855
4591
  shape: zod.z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
@@ -3920,7 +4656,9 @@ const WidgetJSONSchema = zod.z.union([
3920
4656
  GridSymmetryJSONSchema,
3921
4657
  RulerWidgetSchema,
3922
4658
  ProtractorWidgetSchema,
3923
- SetSquareWidgetSchema
4659
+ SetSquareWidgetSchema,
4660
+ ChartBarWidgetJSONSchema,
4661
+ ChartPictoWidgetJSONSchema
3924
4662
  ]);
3925
4663
  const ValidationJSONSchema = zod.z.object({
3926
4664
  mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION, VALIDATION_MODES.MIXED]),
@@ -4014,6 +4752,9 @@ function isVerticalOperationItem(item) {
4014
4752
  function isClockItem(item) {
4015
4753
  return item.type === JSON_TYPES.CLOCK;
4016
4754
  }
4755
+ function isChartItem(item) {
4756
+ return item.type === JSON_TYPES.CHART;
4757
+ }
4017
4758
  function isInlineInputJSON(widget) {
4018
4759
  return widget.type === JSON_TYPES.INLINE_INPUT;
4019
4760
  }
@@ -4035,6 +4776,12 @@ function isFractionJSON(widget) {
4035
4776
  function isGridSymmetryJSON(widget) {
4036
4777
  return widget.type === JSON_TYPES.GRID_SYMMETRY;
4037
4778
  }
4779
+ function isChartBarWidgetJSON(widget) {
4780
+ return widget.type === JSON_TYPES.CHART_BAR;
4781
+ }
4782
+ function isChartPictoWidgetJSON(widget) {
4783
+ return widget.type === JSON_TYPES.CHART_PICTO;
4784
+ }
4038
4785
  function isRulerWidget(widget) {
4039
4786
  return widget.type === JSON_TYPES.RULER;
4040
4787
  }
@@ -8524,7 +9271,7 @@ lodash.exports;
8524
9271
  function valuesIn(object) {
8525
9272
  return object == null ? [] : baseValues(object, keysIn(object));
8526
9273
  }
8527
- function clamp(number, lower, upper) {
9274
+ function clamp2(number, lower, upper) {
8528
9275
  if (upper === undefined$1) {
8529
9276
  upper = lower;
8530
9277
  lower = undefined$1;
@@ -9201,7 +9948,7 @@ lodash.exports;
9201
9948
  lodash2.camelCase = camelCase;
9202
9949
  lodash2.capitalize = capitalize;
9203
9950
  lodash2.ceil = ceil;
9204
- lodash2.clamp = clamp;
9951
+ lodash2.clamp = clamp2;
9205
9952
  lodash2.clone = clone;
9206
9953
  lodash2.cloneDeep = cloneDeep;
9207
9954
  lodash2.cloneDeepWith = cloneDeepWith;
@@ -9625,6 +10372,15 @@ function toJSON(ast) {
9625
10372
  id: idGenerator.generate(JSON_TYPES.LINEBREAK),
9626
10373
  type: JSON_TYPES.LINEBREAK
9627
10374
  });
10375
+ } else if (block.type === AST_TYPES.CHART_BLOCK) {
10376
+ const { item: chartItem, chartWidgets, chartAnswers } = transformChart(
10377
+ block,
10378
+ idGenerator,
10379
+ inlineDragIds
10380
+ );
10381
+ layout.push(chartItem);
10382
+ for (const [id, w] of Object.entries(chartWidgets)) widgets[id] = w;
10383
+ for (const [id, a] of Object.entries(chartAnswers)) answers[id] = a;
9628
10384
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
9629
10385
  const radioBlock = block;
9630
10386
  layout.push({
@@ -9773,12 +10529,18 @@ function toJSON(ast) {
9773
10529
  }
9774
10530
  }
9775
10531
  }
10532
+ let validationSection = {};
10533
+ if (ast.validation) {
10534
+ validationSection = { validation: transformValidation(ast.validation, answers, widgets) };
10535
+ } else if (Object.keys(answers).length > 0) {
10536
+ validationSection = { validation: { mode: VALIDATION_MODES.SIMPLE, answers } };
10537
+ }
9776
10538
  return {
9777
10539
  version: "2.0",
9778
10540
  // Natural DSL version
9779
10541
  layout,
9780
10542
  widgets,
9781
- ...ast.validation ? { validation: transformValidation(ast.validation, answers, widgets) } : {}
10543
+ ...validationSection
9782
10544
  };
9783
10545
  }
9784
10546
  function collectAllInlines(blocks) {
@@ -9811,10 +10573,100 @@ function collectAllInlines(blocks) {
9811
10573
  for (const item of pm.groups.flat()) {
9812
10574
  result.push(...item.inlines);
9813
10575
  }
10576
+ } else if (block.type === AST_TYPES.CHART_BLOCK) {
10577
+ const chart = block;
10578
+ for (const bar of chart.bars) result.push(...bar.inlines);
10579
+ for (const picto of chart.pictos) result.push(...picto.inlines);
10580
+ if (chart.xAxis) result.push(...chart.xAxis.inlines);
10581
+ if (chart.yAxis) result.push(...chart.yAxis.inlines);
9814
10582
  }
9815
10583
  }
9816
10584
  return result;
9817
10585
  }
10586
+ function transformChart(block, idGenerator, inlineDragIds) {
10587
+ const { orientation, valueAxis, categoryAxis } = resolveChartAxes(block);
10588
+ function transformAxisTitle(axis) {
10589
+ return axis !== void 0 && axis.inlines.length > 0 ? axis.inlines.map((inline) => transformInline(inline, idGenerator, inlineDragIds)) : void 0;
10590
+ }
10591
+ function transformNumericAxis(axis) {
10592
+ const title = transformAxisTitle(axis.axis);
10593
+ return {
10594
+ min: axis.min,
10595
+ max: axis.max,
10596
+ step: axis.step,
10597
+ visible: axis.axis?.visible ?? true,
10598
+ ...title !== void 0 && { title }
10599
+ };
10600
+ }
10601
+ function transformCategoryAxis(axis) {
10602
+ const title = transformAxisTitle(axis);
10603
+ const visible = axis?.visible ?? true;
10604
+ if (!title && visible) return void 0;
10605
+ return { visible, ...title && { title } };
10606
+ }
10607
+ function toLabel(inlines) {
10608
+ return inlines.length > 0 ? inlines.map((inline) => transformInline(inline, idGenerator, inlineDragIds)) : void 0;
10609
+ }
10610
+ const xAxis = orientation === "horizontal" ? transformNumericAxis(valueAxis) : transformCategoryAxis(categoryAxis);
10611
+ const yAxis = orientation === "horizontal" ? transformCategoryAxis(categoryAxis) : transformNumericAxis(valueAxis);
10612
+ const chartWidgets = {};
10613
+ const chartAnswers = {};
10614
+ const bars = block.bars.map((bar) => {
10615
+ const label = toLabel(bar.inlines);
10616
+ if (bar.answer !== void 0) {
10617
+ chartWidgets[bar.id] = { type: JSON_TYPES.CHART_BAR, value: bar.value };
10618
+ chartAnswers[bar.id] = bar.answer;
10619
+ return {
10620
+ id: bar.id,
10621
+ type: JSON_TYPES.CHART_BAR,
10622
+ interactive: true,
10623
+ value: bar.value,
10624
+ widgetId: bar.id,
10625
+ ...bar.color !== void 0 && { color: bar.color },
10626
+ ...label !== void 0 && { label }
10627
+ };
10628
+ }
10629
+ return {
10630
+ id: bar.id,
10631
+ type: JSON_TYPES.CHART_BAR,
10632
+ interactive: false,
10633
+ value: bar.value,
10634
+ ...bar.color !== void 0 && { color: bar.color },
10635
+ ...label !== void 0 && { label }
10636
+ };
10637
+ });
10638
+ const pictos = block.pictos.map((picto) => {
10639
+ const label = toLabel(picto.inlines);
10640
+ if (picto.answer !== void 0) {
10641
+ chartWidgets[picto.id] = { type: JSON_TYPES.CHART_PICTO, value: picto.value };
10642
+ chartAnswers[picto.id] = picto.answer;
10643
+ }
10644
+ return {
10645
+ id: picto.id,
10646
+ type: JSON_TYPES.CHART_PICTO,
10647
+ value: picto.value,
10648
+ image: picto.image,
10649
+ ...picto.answer !== void 0 && { answer: picto.answer },
10650
+ ...label !== void 0 && { label }
10651
+ };
10652
+ });
10653
+ const item = {
10654
+ id: idGenerator.generate(JSON_TYPES.CHART),
10655
+ type: JSON_TYPES.CHART,
10656
+ ...block.name !== void 0 && { name: block.name },
10657
+ ...block.title !== void 0 && { title: block.title },
10658
+ chartType: block.chartType,
10659
+ orientation,
10660
+ grid: block.grid,
10661
+ scale: block.scale,
10662
+ ...block.snap !== void 0 && { snap: block.snap },
10663
+ ...xAxis !== void 0 && { xAxis },
10664
+ ...yAxis !== void 0 && { yAxis },
10665
+ bars,
10666
+ pictos
10667
+ };
10668
+ return { item, chartWidgets, chartAnswers };
10669
+ }
9818
10670
  function transformNumberLine(numberLine, idGenerator, inlineDragIds) {
9819
10671
  const positions = numberLine.positions.map((pos) => {
9820
10672
  const content = pos.inlines.map((inline) => transformInline(inline, idGenerator, inlineDragIds));
@@ -10181,17 +11033,6 @@ function extractVariables$1(expression, validVariables) {
10181
11033
  }
10182
11034
  return uniqueVars;
10183
11035
  }
10184
- function normalize(value) {
10185
- if (typeof value === "number") {
10186
- return String(Math.round(value * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
10187
- }
10188
- const trimmed = String(value).trim().toLowerCase().replace(/^["']|["']$/g, "");
10189
- const asNum = Number(trimmed);
10190
- if (trimmed !== "" && !isNaN(asNum)) {
10191
- return String(Math.round(asNum * 10 ** INTERNAL_PRECISION) / 10 ** INTERNAL_PRECISION);
10192
- }
10193
- return trimmed;
10194
- }
10195
11036
  const NL_DRAG_PREFIX = "\0nl-drag\0";
10196
11037
  const isNLDragEncoded = (v) => v.startsWith(NL_DRAG_PREFIX);
10197
11038
  const encodeNLDragValue = (label, position) => `${NL_DRAG_PREFIX}${label}@${String(position)}`;
@@ -10340,16 +11181,6 @@ function validateSimple(exercise, userInputs) {
10340
11181
  allCorrect = false;
10341
11182
  }
10342
11183
  }
10343
- for (const widgetId of Object.keys(userInputs)) {
10344
- if (!(widgetId in answers) && !(widgetId in details)) {
10345
- details[widgetId] = {
10346
- widgetId,
10347
- userValue: userInputs[widgetId],
10348
- isCorrect: true
10349
- // No validation rule = assume correct
10350
- };
10351
- }
10352
- }
10353
11184
  return {
10354
11185
  isCorrect: allCorrect,
10355
11186
  details,
@@ -14523,7 +15354,9 @@ const DEFAULT_SOUNDS = {
14523
15354
  "input-select": inputSelect,
14524
15355
  "answer-correct": answerCorrect,
14525
15356
  "answer-wrong": answerWrong,
14526
- "answer-reveal": answerReveal
15357
+ "answer-reveal": answerReveal,
15358
+ "bar-step": buttonClick,
15359
+ "bar-release": dragDrop
14527
15360
  };
14528
15361
  function resolveSounds(overrides) {
14529
15362
  return overrides ? { ...DEFAULT_SOUNDS, ...overrides } : DEFAULT_SOUNDS;
@@ -14808,27 +15641,41 @@ function useRotate(options) {
14808
15641
  return { rotation, setRotation };
14809
15642
  }
14810
15643
  const REPEAT_DELAY_MS = 5e3;
14811
- function useJiggle(isDragging) {
15644
+ function useJiggle(isDiscovered, options = {}) {
15645
+ const {
15646
+ ignoreBubbledAnimationEnd = false,
15647
+ repeatDelayMs = REPEAT_DELAY_MS
15648
+ } = options;
14812
15649
  const [jiggling, setJiggling] = React.useState(true);
14813
15650
  const [discovered, setDiscovered] = React.useState(false);
15651
+ const discoveredRef = React.useRef(false);
14814
15652
  const timerRef = React.useRef(null);
14815
- React.useEffect(() => {
14816
- if (isDragging && !discovered) {
14817
- setDiscovered(true);
14818
- if (timerRef.current) clearTimeout(timerRef.current);
14819
- setJiggling(false);
14820
- }
14821
- }, [isDragging, discovered]);
14822
- React.useEffect(() => () => {
14823
- if (timerRef.current) clearTimeout(timerRef.current);
15653
+ const clearRepeatTimer = React.useCallback(() => {
15654
+ if (!timerRef.current) return;
15655
+ clearTimeout(timerRef.current);
15656
+ timerRef.current = null;
14824
15657
  }, []);
14825
- const onAnimationEnd = React.useCallback(() => {
15658
+ const discover = React.useCallback(() => {
15659
+ discoveredRef.current = true;
15660
+ clearRepeatTimer();
15661
+ setDiscovered(true);
14826
15662
  setJiggling(false);
14827
- if (!discovered) {
14828
- timerRef.current = setTimeout(() => setJiggling(true), REPEAT_DELAY_MS);
14829
- }
14830
- }, [discovered]);
14831
- return { jiggling, onAnimationEnd };
15663
+ }, [clearRepeatTimer]);
15664
+ React.useEffect(() => {
15665
+ if (isDiscovered) discover();
15666
+ }, [isDiscovered, discover]);
15667
+ React.useEffect(() => () => clearRepeatTimer(), [clearRepeatTimer]);
15668
+ const onAnimationEnd = React.useCallback((event) => {
15669
+ if (ignoreBubbledAnimationEnd && event && event.currentTarget !== event.target) return;
15670
+ setJiggling(false);
15671
+ if (discoveredRef.current) return;
15672
+ clearRepeatTimer();
15673
+ timerRef.current = setTimeout(() => {
15674
+ timerRef.current = null;
15675
+ if (!discoveredRef.current) setJiggling(true);
15676
+ }, repeatDelayMs);
15677
+ }, [clearRepeatTimer, ignoreBubbledAnimationEnd, repeatDelayMs]);
15678
+ return { jiggling: jiggling && !discovered, onAnimationEnd, discover };
14832
15679
  }
14833
15680
  const VW$2 = 320;
14834
15681
  const VH$2 = 44;
@@ -16333,6 +17180,743 @@ const GridSymmetry = ({
16333
17180
  }
16334
17181
  ) });
16335
17182
  };
17183
+ function getChartClassName(item) {
17184
+ const isHorizontal = item.orientation === "horizontal";
17185
+ return [
17186
+ "chart",
17187
+ isHorizontal ? "chart--horizontal" : "",
17188
+ item.chartType === "pictograph" ? "chart--pictograph" : "",
17189
+ item.xAxis?.visible === false ? "chart--no-xaxis" : "",
17190
+ item.yAxis?.visible === false ? "chart--no-yaxis" : ""
17191
+ ].filter(Boolean).join(" ");
17192
+ }
17193
+ function resolveItemValue(id, defaultValue2, values) {
17194
+ const raw = values[id];
17195
+ const parsed = parseFloat(raw ?? "");
17196
+ return isNaN(parsed) ? defaultValue2 : parsed;
17197
+ }
17198
+ function resolveValue(bar, values) {
17199
+ return bar.interactive ? resolveItemValue(bar.widgetId, bar.value, values) : bar.value;
17200
+ }
17201
+ function clamp(v, min, max) {
17202
+ return Math.min(max, Math.max(min, v));
17203
+ }
17204
+ function snapToStep(v, min, step) {
17205
+ const snapped = min + Math.round((v - min) / step) * step;
17206
+ return +snapped.toFixed(10);
17207
+ }
17208
+ function nextStepValue(v, axis, direction, snap) {
17209
+ const min = axis.min ?? CHART_AXIS_DEFAULTS.min;
17210
+ const max = axis.max ?? CHART_AXIS_DEFAULTS.max;
17211
+ const step = snap ?? axis.step ?? CHART_AXIS_DEFAULTS.step;
17212
+ const raw = direction === 1 ? min + Math.ceil((v - min) / step + 1e-9) * step : min + Math.floor((v - min) / step - 1e-9) * step;
17213
+ return +clamp(raw, min, max).toFixed(10);
17214
+ }
17215
+ function toFill(v, axis) {
17216
+ const min = axis.min ?? CHART_AXIS_DEFAULTS.min;
17217
+ const max = axis.max ?? CHART_AXIS_DEFAULTS.max;
17218
+ return clamp((v - min) / (max - min), 0, 1);
17219
+ }
17220
+ function barClasses(base, validationStatus) {
17221
+ const statusClass = validationStatus === "correct" ? "chart-bar-correct" : validationStatus === "incorrect" ? "chart-bar-incorrect" : null;
17222
+ return [...base, ...statusClass ? [statusClass] : []].join(" ");
17223
+ }
17224
+ function buildAxisTicks(axis) {
17225
+ const min = axis.min ?? CHART_AXIS_DEFAULTS.min;
17226
+ const max = axis.max ?? CHART_AXIS_DEFAULTS.max;
17227
+ const step = axis.step ?? CHART_AXIS_DEFAULTS.step;
17228
+ const ticks = [];
17229
+ for (let v = min; v <= max + 1e-9; v = +(v + step).toFixed(10)) {
17230
+ ticks.push(v);
17231
+ }
17232
+ if (ticks.length === 0 || Math.abs(ticks[ticks.length - 1] - max) > 1e-9) {
17233
+ ticks.push(max);
17234
+ }
17235
+ return ticks;
17236
+ }
17237
+ function axisTickPercent(value, axis) {
17238
+ const min = axis.min ?? CHART_AXIS_DEFAULTS.min;
17239
+ const max = axis.max ?? CHART_AXIS_DEFAULTS.max;
17240
+ return (value - min) / (max - min) * 100;
17241
+ }
17242
+ function buildAxisTickPositions(axis) {
17243
+ return buildAxisTicks(axis).map((value) => ({
17244
+ value,
17245
+ percent: axisTickPercent(value, axis)
17246
+ }));
17247
+ }
17248
+ function formatAxisTick(value) {
17249
+ return Number.isInteger(value) ? value : parseFloat(value.toPrecision(10));
17250
+ }
17251
+ const CHART_METRICS = {
17252
+ scaleMin: 0.6,
17253
+ scaleMax: 1.6,
17254
+ slotWidth: 5.2,
17255
+ gapWidth: 1,
17256
+ axisWidth: 2.35,
17257
+ barWidth: 3.35,
17258
+ fontSize: 0.9,
17259
+ labelFontSize: 0.98,
17260
+ handleSize: 1.15,
17261
+ chartHeight: 16
17262
+ };
17263
+ function computeBaseChartVars(scale, barCount) {
17264
+ const visualScale = clamp(scale, CHART_METRICS.scaleMin, CHART_METRICS.scaleMax);
17265
+ const slotWidth = CHART_METRICS.slotWidth * visualScale;
17266
+ const gapWidth = CHART_METRICS.gapWidth * visualScale;
17267
+ const trackLength = barCount * slotWidth + Math.max(0, barCount - 1) * gapWidth + gapWidth * 2;
17268
+ return {
17269
+ visualScale,
17270
+ trackLength,
17271
+ cssVars: {
17272
+ "--chart-axis-width": `${CHART_METRICS.axisWidth * visualScale}rem`,
17273
+ "--chart-bar-slot-width": `${slotWidth}rem`,
17274
+ "--chart-bar-width": `${CHART_METRICS.barWidth * visualScale}rem`,
17275
+ "--chart-bar-gap": `${gapWidth}rem`,
17276
+ "--chart-font-size": `${CHART_METRICS.fontSize * visualScale}rem`,
17277
+ "--chart-label-font-size": `${CHART_METRICS.labelFontSize * visualScale}rem`,
17278
+ "--chart-handle-size": `${CHART_METRICS.handleSize * visualScale}rem`
17279
+ }
17280
+ };
17281
+ }
17282
+ function computeChartStyle(scale, barCount) {
17283
+ const { trackLength, visualScale, cssVars } = computeBaseChartVars(scale, barCount);
17284
+ return {
17285
+ ...cssVars,
17286
+ "--chart-height": `${CHART_METRICS.chartHeight * visualScale}rem`,
17287
+ "--chart-content-width": `${trackLength}rem`
17288
+ };
17289
+ }
17290
+ function computePictographChartStyle(scale, columnCount, totalSlots, orientation = "vertical") {
17291
+ const slots = Math.max(0, totalSlots);
17292
+ const gaps = Math.max(0, slots - 1);
17293
+ const slotSpan = `calc(${slots} * var(--picto-cell-size) + ${gaps} * var(--picto-cell-gap))`;
17294
+ if (orientation === "horizontal") {
17295
+ return {
17296
+ ...computeHorizontalChartStyle(scale, columnCount),
17297
+ "--picto-total-slots": slots,
17298
+ // Horizontal columns run along the width, so the value axis is the width here.
17299
+ // Both vars move together to keep the X-axis ticks aligned with the icons.
17300
+ "--chart-h-value-width": slotSpan,
17301
+ "--chart-content-width": slotSpan
17302
+ };
17303
+ }
17304
+ return {
17305
+ ...computeChartStyle(scale, columnCount),
17306
+ "--picto-total-slots": slots,
17307
+ "--chart-height": slotSpan
17308
+ };
17309
+ }
17310
+ function computeHorizontalChartStyle(scale, barCount) {
17311
+ const { trackLength, visualScale, cssVars } = computeBaseChartVars(scale, barCount);
17312
+ const valueWidth = CHART_METRICS.chartHeight * visualScale;
17313
+ return {
17314
+ ...cssVars,
17315
+ "--chart-height": `${trackLength}rem`,
17316
+ "--chart-h-value-width": `${valueWidth}rem`,
17317
+ "--chart-content-width": `${valueWidth}rem`
17318
+ };
17319
+ }
17320
+ const YAxis = ({ axis }) => {
17321
+ const ticks = buildAxisTickPositions(axis);
17322
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-y", children: ticks.map(({ value, percent }) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: "chart-axis-tick-y", style: { bottom: `${percent}%` }, children: formatAxisTick(value) }, value)) });
17323
+ };
17324
+ const Grid = ({ axis, orientation = "vertical" }) => {
17325
+ const lines = buildAxisTickPositions(axis);
17326
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: lines.map(({ value, percent }) => {
17327
+ const style = orientation === "horizontal" ? { left: `${percent}%` } : { bottom: `${percent}%` };
17328
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-gridline", style }, value);
17329
+ }) });
17330
+ };
17331
+ const XAxis = ({ axis, title }) => {
17332
+ const ticks = buildAxisTickPositions(axis);
17333
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "chart-axis-x-numeric", children: [
17334
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-x-numeric-ticks", children: ticks.map(({ value, percent }) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: "chart-axis-tick-x-num", style: { left: `${percent}%` }, children: formatAxisTick(value) }, value)) }),
17335
+ title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--x", children: title })
17336
+ ] });
17337
+ };
17338
+ const DraggableBar = ({
17339
+ bar,
17340
+ currentValue,
17341
+ axis,
17342
+ snap,
17343
+ validationStatus,
17344
+ orientation = "vertical",
17345
+ onChange
17346
+ }) => {
17347
+ const trackRef = React.useRef(null);
17348
+ const dragging = React.useRef(false);
17349
+ const lastSnap = React.useRef(null);
17350
+ const preDragValue = React.useRef(0);
17351
+ const playSound = useSound();
17352
+ const exerciseLocked = useExerciseLocked();
17353
+ const [isDragging, setIsDragging] = React.useState(false);
17354
+ const { jiggling, onAnimationEnd, discover } = useJiggle(isDragging, {
17355
+ ignoreBubbledAnimationEnd: true
17356
+ });
17357
+ const axisMin = axis.min ?? CHART_AXIS_DEFAULTS.min;
17358
+ const axisMax = axis.max ?? CHART_AXIS_DEFAULTS.max;
17359
+ const axisStep = axis.step ?? CHART_AXIS_DEFAULTS.step;
17360
+ const snapStep = snap ?? axisStep;
17361
+ const fillFromPointer = React.useCallback(
17362
+ (clientY, clientX) => {
17363
+ const track = trackRef.current;
17364
+ if (!track) return;
17365
+ const rect = track.getBoundingClientRect();
17366
+ if (rect.width === 0 || rect.height === 0) return;
17367
+ let ratio;
17368
+ if (orientation === "horizontal") {
17369
+ ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
17370
+ } else {
17371
+ ratio = clamp(1 - (clientY - rect.top) / rect.height, 0, 1);
17372
+ }
17373
+ const rawValue = axisMin + ratio * (axisMax - axisMin);
17374
+ const snapped = clamp(snapToStep(rawValue, axisMin, snapStep), axisMin, axisMax);
17375
+ if (snapped !== lastSnap.current) {
17376
+ lastSnap.current = snapped;
17377
+ playSound("bar-step");
17378
+ onChange(bar.widgetId, String(snapped));
17379
+ }
17380
+ },
17381
+ [axisMin, axisMax, snapStep, bar.widgetId, onChange, orientation, playSound]
17382
+ );
17383
+ const onPointerDown = React.useCallback(
17384
+ (e) => {
17385
+ if (exerciseLocked) return;
17386
+ e.preventDefault();
17387
+ discover();
17388
+ dragging.current = true;
17389
+ preDragValue.current = currentValue;
17390
+ lastSnap.current = clamp(snapToStep(currentValue, axisMin, snapStep), axisMin, axisMax);
17391
+ setIsDragging(true);
17392
+ e.currentTarget.setPointerCapture(e.pointerId);
17393
+ fillFromPointer(e.clientY, e.clientX);
17394
+ },
17395
+ [fillFromPointer, currentValue, axisMin, axisMax, snapStep, discover, exerciseLocked]
17396
+ );
17397
+ const onPointerMove = React.useCallback(
17398
+ (e) => {
17399
+ if (!dragging.current) return;
17400
+ fillFromPointer(e.clientY, e.clientX);
17401
+ },
17402
+ [fillFromPointer]
17403
+ );
17404
+ const onPointerUp = React.useCallback(() => {
17405
+ if (!dragging.current) return;
17406
+ dragging.current = false;
17407
+ setIsDragging(false);
17408
+ playSound("bar-release");
17409
+ }, [playSound]);
17410
+ const onPointerCancel = React.useCallback(() => {
17411
+ if (!dragging.current) return;
17412
+ dragging.current = false;
17413
+ setIsDragging(false);
17414
+ onChange(bar.widgetId, String(preDragValue.current));
17415
+ }, [bar.widgetId, onChange]);
17416
+ const onKeyDown = React.useCallback(
17417
+ (e) => {
17418
+ if (exerciseLocked) return;
17419
+ const isInc = e.key === "ArrowUp" || e.key === "ArrowRight";
17420
+ const isDec = e.key === "ArrowDown" || e.key === "ArrowLeft";
17421
+ if (isInc) {
17422
+ e.preventDefault();
17423
+ playSound("bar-step");
17424
+ onChange(bar.widgetId, String(nextStepValue(currentValue, axis, 1, snap)));
17425
+ } else if (isDec) {
17426
+ e.preventDefault();
17427
+ playSound("bar-step");
17428
+ onChange(bar.widgetId, String(nextStepValue(currentValue, axis, -1, snap)));
17429
+ } else if (e.key === "Home") {
17430
+ e.preventDefault();
17431
+ playSound("bar-step");
17432
+ onChange(bar.widgetId, String(axisMin));
17433
+ } else if (e.key === "End") {
17434
+ e.preventDefault();
17435
+ playSound("bar-step");
17436
+ onChange(bar.widgetId, String(axisMax));
17437
+ }
17438
+ },
17439
+ [axisMin, axisMax, bar.widgetId, currentValue, axis, snap, onChange, playSound, exerciseLocked]
17440
+ );
17441
+ const fill = toFill(currentValue, axis);
17442
+ const isAtMin = currentValue <= axisMin;
17443
+ const ariaLabel = bar.label?.map((item) => "value" in item ? String(item.value) : "").filter(Boolean).join(" ") || bar.id;
17444
+ return /* @__PURE__ */ jsxRuntime.jsx(
17445
+ "div",
17446
+ {
17447
+ ref: trackRef,
17448
+ className: "chart-bar-track chart-bar-track--interactive",
17449
+ children: /* @__PURE__ */ jsxRuntime.jsx(
17450
+ "div",
17451
+ {
17452
+ role: "slider",
17453
+ tabIndex: 0,
17454
+ "aria-valuemin": axisMin,
17455
+ "aria-valuemax": axisMax,
17456
+ "aria-valuenow": currentValue,
17457
+ "aria-orientation": orientation === "horizontal" ? "horizontal" : "vertical",
17458
+ "aria-label": ariaLabel,
17459
+ "aria-disabled": exerciseLocked ? "true" : void 0,
17460
+ className: barClasses(
17461
+ [
17462
+ "chart-bar",
17463
+ orientation === "horizontal" ? "chart-bar--horizontal" : "chart-bar--vertical",
17464
+ "chart-bar-interactive",
17465
+ isAtMin ? "chart-bar-at-min" : "",
17466
+ jiggling && !isDragging && !exerciseLocked && validationStatus === null ? "chart-bar--peek" : ""
17467
+ ],
17468
+ validationStatus
17469
+ ),
17470
+ onAnimationEnd,
17471
+ onPointerDown,
17472
+ onPointerMove,
17473
+ onPointerUp,
17474
+ onPointerCancel,
17475
+ onKeyDown,
17476
+ style: {
17477
+ "--bar-fill": fill,
17478
+ "--chart-bar-instance-color": bar.color ?? "var(--chart-bar-color)"
17479
+ },
17480
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "chart-bar-handle", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { viewBox: "0 0 10 10", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: [
17481
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M1.5 4.8 L5 1.5 L8.5 4.8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }),
17482
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M1.5 8.5 L5 5.2 L8.5 8.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })
17483
+ ] }) })
17484
+ }
17485
+ )
17486
+ }
17487
+ );
17488
+ };
17489
+ const StaticBar = ({
17490
+ bar,
17491
+ currentValue,
17492
+ axis,
17493
+ validationStatus,
17494
+ orientation = "vertical"
17495
+ }) => {
17496
+ const fill = toFill(currentValue, axis);
17497
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bar-track", children: /* @__PURE__ */ jsxRuntime.jsx(
17498
+ "div",
17499
+ {
17500
+ className: barClasses(
17501
+ [
17502
+ "chart-bar",
17503
+ orientation === "horizontal" ? "chart-bar--horizontal" : "chart-bar--vertical"
17504
+ ],
17505
+ validationStatus
17506
+ ),
17507
+ style: {
17508
+ "--bar-fill": fill,
17509
+ "--chart-bar-instance-color": bar.color ?? "var(--chart-bar-color)",
17510
+ backgroundColor: "var(--chart-bar-instance-color)"
17511
+ }
17512
+ }
17513
+ ) });
17514
+ };
17515
+ const EMPTY_SET$2 = /* @__PURE__ */ new Set();
17516
+ const XAxisLabel = ({
17517
+ bar,
17518
+ widgets,
17519
+ values,
17520
+ validationDetails,
17521
+ onChange
17522
+ }) => {
17523
+ if (!bar.label || bar.label.length === 0) return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-tick-x" });
17524
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-tick-x", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-tick-x-inner", children: bar.label.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
17525
+ LayoutRenderer,
17526
+ {
17527
+ item: subItem,
17528
+ widgets,
17529
+ values,
17530
+ droppedInputs: EMPTY_SET$2,
17531
+ validationDetails,
17532
+ onChange
17533
+ },
17534
+ subItem.id
17535
+ )) }) });
17536
+ };
17537
+ const EMPTY_SET$1 = /* @__PURE__ */ new Set();
17538
+ const BarChart = ({
17539
+ item,
17540
+ widgets,
17541
+ values,
17542
+ validationDetails,
17543
+ onChange
17544
+ }) => {
17545
+ const { bars, grid } = item;
17546
+ const isHorizontal = item.orientation === "horizontal";
17547
+ const valueAxis = isHorizontal ? item.xAxis : item.yAxis;
17548
+ const categoryAxis = isHorizontal ? item.yAxis : item.xAxis;
17549
+ const chartClass = getChartClassName(item);
17550
+ const renderAxisTitle = (title) => {
17551
+ if (!title?.length) return null;
17552
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: title.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
17553
+ LayoutRenderer,
17554
+ {
17555
+ item: subItem,
17556
+ widgets,
17557
+ values,
17558
+ droppedInputs: EMPTY_SET$1,
17559
+ validationDetails,
17560
+ onChange
17561
+ },
17562
+ subItem.id
17563
+ )) });
17564
+ };
17565
+ const fillAxis = valueAxis ?? CHART_AXIS_DEFAULTS;
17566
+ const renderBar = (bar, orient = "vertical") => {
17567
+ const currentValue = resolveValue(bar, values);
17568
+ const vStatus = bar.interactive ? validationDetails?.[bar.widgetId] : void 0;
17569
+ const validationStatus = vStatus?.isCorrect === true ? "correct" : vStatus?.isCorrect === false ? "incorrect" : null;
17570
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bar-wrapper", children: bar.interactive ? /* @__PURE__ */ jsxRuntime.jsx(
17571
+ DraggableBar,
17572
+ {
17573
+ bar,
17574
+ currentValue,
17575
+ axis: fillAxis,
17576
+ snap: item.snap,
17577
+ validationStatus,
17578
+ orientation: orient,
17579
+ onChange
17580
+ }
17581
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
17582
+ StaticBar,
17583
+ {
17584
+ bar,
17585
+ currentValue,
17586
+ axis: fillAxis,
17587
+ validationStatus,
17588
+ orientation: orient
17589
+ }
17590
+ ) }, bar.id);
17591
+ };
17592
+ if (isHorizontal) {
17593
+ const chartStyle2 = computeHorizontalChartStyle(item.scale, bars.length);
17594
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-container", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-scroll", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: chartClass, dir: "ltr", style: chartStyle2, children: [
17595
+ item.title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-title", children: item.title }),
17596
+ categoryAxis?.visible && categoryAxis.title?.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--y-h", children: renderAxisTitle(categoryAxis.title) }) : null,
17597
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-y--categories", children: bars.map((bar) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bar-y-label", children: bar.label?.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
17598
+ LayoutRenderer,
17599
+ {
17600
+ item: subItem,
17601
+ widgets,
17602
+ values,
17603
+ droppedInputs: EMPTY_SET$1,
17604
+ validationDetails,
17605
+ onChange
17606
+ },
17607
+ subItem.id
17608
+ )) }, bar.id)) }),
17609
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "chart-plot", children: [
17610
+ grid && /* @__PURE__ */ jsxRuntime.jsx(Grid, { axis: fillAxis, orientation: "horizontal" }),
17611
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bars", children: bars.map((b) => renderBar(b, "horizontal")) })
17612
+ ] }),
17613
+ valueAxis && valueAxis.visible && /* @__PURE__ */ jsxRuntime.jsx(XAxis, { axis: valueAxis, title: renderAxisTitle(valueAxis.title) })
17614
+ ] }) }) });
17615
+ }
17616
+ const chartStyle = computeChartStyle(item.scale, bars.length);
17617
+ const hasAnyLabel = bars.some((b) => b.label && b.label.length > 0);
17618
+ const xAxisRow = hasAnyLabel ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-x", children: bars.map((bar) => /* @__PURE__ */ jsxRuntime.jsx(
17619
+ XAxisLabel,
17620
+ {
17621
+ bar,
17622
+ widgets,
17623
+ values,
17624
+ validationDetails,
17625
+ onChange
17626
+ },
17627
+ bar.id
17628
+ )) }) : null;
17629
+ const xAxisTitle = categoryAxis?.visible && categoryAxis.title?.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--x-category", children: renderAxisTitle(categoryAxis.title) }) : null;
17630
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-container", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-scroll", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: chartClass, dir: "ltr", style: chartStyle, children: [
17631
+ item.title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-title", children: item.title }),
17632
+ valueAxis?.visible && valueAxis.title?.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--y", children: renderAxisTitle(valueAxis.title) }) : null,
17633
+ valueAxis && valueAxis.visible && /* @__PURE__ */ jsxRuntime.jsx(YAxis, { axis: valueAxis }),
17634
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "chart-plot", children: [
17635
+ grid && /* @__PURE__ */ jsxRuntime.jsx(Grid, { axis: fillAxis }),
17636
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bars", children: bars.map((b) => renderBar(b)) })
17637
+ ] }),
17638
+ xAxisRow,
17639
+ xAxisTitle
17640
+ ] }) }) });
17641
+ };
17642
+ const clampSlotCount = (value, axisMin, totalSlots) => Math.round(Math.min(Math.max(value - axisMin, 0), totalSlots));
17643
+ const buildSelectedAnswerSlots = (totalSlots, baseFilledCount, selectedCount) => {
17644
+ const slots = Array.from({ length: totalSlots }, () => false);
17645
+ const end = Math.min(totalSlots, baseFilledCount + selectedCount);
17646
+ for (let i = baseFilledCount; i < end; i++) slots[i] = true;
17647
+ return slots;
17648
+ };
17649
+ const PictographColumn = ({
17650
+ picto,
17651
+ currentValue,
17652
+ axis,
17653
+ validationStatus,
17654
+ orientation = "vertical",
17655
+ onChange
17656
+ }) => {
17657
+ const axisMin = axis.min ?? CHART_AXIS_DEFAULTS.min;
17658
+ const axisMax = axis.max ?? CHART_AXIS_DEFAULTS.max;
17659
+ const isInteractive = picto.answer !== void 0;
17660
+ const totalSlots = Math.round(axisMax - axisMin);
17661
+ const baseFilledCount = clampSlotCount(picto.value, axisMin, totalSlots);
17662
+ const currentFilledCount = clampSlotCount(currentValue, axisMin, totalSlots);
17663
+ const answerSlotCount = Math.max(0, totalSlots - baseFilledCount);
17664
+ const selectedAnswerCount = Math.min(
17665
+ Math.max(currentFilledCount - baseFilledCount, 0),
17666
+ answerSlotCount
17667
+ );
17668
+ const [selectedSlots, setSelectedSlots] = React.useState(
17669
+ () => buildSelectedAnswerSlots(totalSlots, baseFilledCount, selectedAnswerCount)
17670
+ );
17671
+ const lastInternalValueRef = React.useRef(null);
17672
+ const [hasInteracted, setHasInteracted] = React.useState(false);
17673
+ const { jiggling: hinting, onAnimationEnd: onHintEnd } = useJiggle(hasInteracted);
17674
+ const playSound = useSound();
17675
+ const exerciseLocked = useExerciseLocked();
17676
+ React.useEffect(() => {
17677
+ const nextValue = String(axisMin + baseFilledCount + selectedAnswerCount);
17678
+ if (nextValue === lastInternalValueRef.current) return;
17679
+ lastInternalValueRef.current = null;
17680
+ setSelectedSlots(buildSelectedAnswerSlots(totalSlots, baseFilledCount, selectedAnswerCount));
17681
+ }, [axisMin, baseFilledCount, selectedAnswerCount, totalSlots]);
17682
+ const handleCellClick = React.useCallback(
17683
+ (cellIndex) => {
17684
+ if (exerciseLocked || !isInteractive || cellIndex < baseFilledCount) return;
17685
+ if (!hasInteracted) setHasInteracted(true);
17686
+ const slotRelative = cellIndex - baseFilledCount;
17687
+ const isCurrentlyFilled = Boolean(selectedSlots[cellIndex]);
17688
+ const nextCount = isCurrentlyFilled ? slotRelative : slotRelative + 1;
17689
+ const nextSlots = buildSelectedAnswerSlots(totalSlots, baseFilledCount, nextCount);
17690
+ const nextValue = axisMin + baseFilledCount + nextCount;
17691
+ lastInternalValueRef.current = String(nextValue);
17692
+ setSelectedSlots(nextSlots);
17693
+ onChange(picto.id, String(nextValue));
17694
+ playSound("fraction-shade");
17695
+ },
17696
+ [exerciseLocked, isInteractive, baseFilledCount, totalSlots, selectedSlots, axisMin, onChange, picto.id, playSound, hasInteracted]
17697
+ );
17698
+ const statusClass = validationStatus === "correct" ? "picto-column--correct" : validationStatus === "incorrect" ? "picto-column--incorrect" : "";
17699
+ const cellCount = isInteractive ? totalSlots : currentFilledCount;
17700
+ const cells = Array.from({ length: cellCount }, (_, i) => {
17701
+ const isPrefilled = isInteractive ? i < baseFilledCount : i < currentFilledCount;
17702
+ const isSelectedAnswer = isInteractive && i >= baseFilledCount && Boolean(selectedSlots[i]);
17703
+ const canToggle = isInteractive && i >= baseFilledCount;
17704
+ const isActive = isPrefilled || isSelectedAnswer;
17705
+ const isInactiveFn = !isActive && isInteractive;
17706
+ const isLastInteractive = canToggle && i === cellCount - 1;
17707
+ const animClass = validationStatus === "correct" && isActive ? "picto-cell--pop" : validationStatus === "incorrect" && isActive ? "picto-cell--jiggle" : hinting && validationStatus === null && !exerciseLocked && canToggle && !isActive ? "picto-cell--hint" : "";
17708
+ const cellClass = [
17709
+ "picto-cell",
17710
+ isInactiveFn ? "picto-cell--inactive" : "",
17711
+ canToggle ? "picto-cell--interactive" : "",
17712
+ isInteractive && !canToggle ? "picto-cell--locked" : "",
17713
+ animClass
17714
+ ].filter(Boolean).join(" ");
17715
+ const imgStyle = animClass === "picto-cell--pop" || animClass === "picto-cell--jiggle" ? { animationDelay: `${i * 50}ms` } : animClass === "picto-cell--hint" ? { animationDelay: `${(i - baseFilledCount) * 80}ms` } : void 0;
17716
+ return /* @__PURE__ */ jsxRuntime.jsx(
17717
+ "div",
17718
+ {
17719
+ className: cellClass,
17720
+ onClick: canToggle ? () => handleCellClick(i) : void 0,
17721
+ role: isInteractive ? "checkbox" : void 0,
17722
+ "aria-checked": isInteractive ? isActive : void 0,
17723
+ "aria-disabled": isInteractive && (!canToggle || exerciseLocked) ? true : void 0,
17724
+ "aria-label": isInteractive ? `${picto.id} icon ${i + 1}` : void 0,
17725
+ tabIndex: canToggle ? 0 : void 0,
17726
+ onKeyDown: canToggle ? (e) => {
17727
+ if (e.key === "Enter" || e.key === " ") {
17728
+ e.preventDefault();
17729
+ handleCellClick(i);
17730
+ }
17731
+ } : void 0,
17732
+ children: /* @__PURE__ */ jsxRuntime.jsx(
17733
+ "img",
17734
+ {
17735
+ src: picto.image,
17736
+ alt: "",
17737
+ "aria-hidden": "true",
17738
+ style: imgStyle,
17739
+ onAnimationEnd: isLastInteractive && animClass === "picto-cell--hint" ? onHintEnd : void 0
17740
+ }
17741
+ )
17742
+ },
17743
+ i
17744
+ );
17745
+ });
17746
+ const columnClass = [
17747
+ "chart-picto-column-wrapper",
17748
+ orientation === "horizontal" ? "chart-picto-column-wrapper--h" : "",
17749
+ statusClass
17750
+ ].filter(Boolean).join(" ");
17751
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: columnClass, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: `chart-picto-cells${orientation === "horizontal" ? " chart-picto-cells--h" : ""}`, children: cells }) });
17752
+ };
17753
+ const PictographGrid = ({
17754
+ axis,
17755
+ columnCount,
17756
+ orientation = "vertical"
17757
+ }) => {
17758
+ const axisMin = axis.min ?? CHART_AXIS_DEFAULTS.min;
17759
+ const axisMax = axis.max ?? CHART_AXIS_DEFAULTS.max;
17760
+ const totalSlots = Math.round(axisMax - axisMin);
17761
+ if (orientation === "horizontal") {
17762
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
17763
+ Array.from({ length: columnCount - 1 }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx(
17764
+ "div",
17765
+ {
17766
+ className: "picto-gridline picto-gridline--row",
17767
+ style: {
17768
+ top: `calc(var(--chart-bar-gap) + ${i + 1} * (var(--chart-bar-slot-width) + var(--chart-bar-gap)))`
17769
+ }
17770
+ },
17771
+ `row-${i}`
17772
+ )),
17773
+ Array.from({ length: totalSlots - 1 }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx(
17774
+ "div",
17775
+ {
17776
+ className: "picto-gridline picto-gridline--col",
17777
+ style: {
17778
+ left: `calc(${i + 1} * (var(--picto-cell-size) + var(--picto-cell-gap)))`
17779
+ }
17780
+ },
17781
+ `col-${i}`
17782
+ ))
17783
+ ] });
17784
+ }
17785
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
17786
+ Array.from({ length: totalSlots - 1 }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx(
17787
+ "div",
17788
+ {
17789
+ className: "picto-gridline picto-gridline--row",
17790
+ style: {
17791
+ bottom: `calc(${i + 1} * (var(--picto-cell-size) + var(--picto-cell-gap)))`
17792
+ }
17793
+ },
17794
+ `row-${i}`
17795
+ )),
17796
+ Array.from({ length: columnCount - 1 }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx(
17797
+ "div",
17798
+ {
17799
+ className: "picto-gridline picto-gridline--col",
17800
+ style: {
17801
+ left: `calc(var(--chart-bar-gap) + ${i + 1} * (var(--chart-bar-slot-width) + var(--chart-bar-gap)))`
17802
+ }
17803
+ },
17804
+ `col-${i}`
17805
+ ))
17806
+ ] });
17807
+ };
17808
+ const EMPTY_SET = /* @__PURE__ */ new Set();
17809
+ const PictographChart = ({
17810
+ item,
17811
+ widgets,
17812
+ values,
17813
+ validationDetails,
17814
+ onChange
17815
+ }) => {
17816
+ const { pictos = [], grid } = item;
17817
+ const isHorizontal = item.orientation === "horizontal";
17818
+ const valueAxis = isHorizontal ? item.xAxis : item.yAxis;
17819
+ const categoryAxis = isHorizontal ? item.yAxis : item.xAxis;
17820
+ const chartClass = getChartClassName(item);
17821
+ const renderAxisTitle = (title) => {
17822
+ if (!title?.length) return null;
17823
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: title.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
17824
+ LayoutRenderer,
17825
+ {
17826
+ item: subItem,
17827
+ widgets,
17828
+ values,
17829
+ droppedInputs: EMPTY_SET,
17830
+ validationDetails,
17831
+ onChange
17832
+ },
17833
+ subItem.id
17834
+ )) });
17835
+ };
17836
+ const fillAxis = valueAxis ?? CHART_AXIS_DEFAULTS;
17837
+ const totalSlots = Math.round(
17838
+ (fillAxis.max ?? CHART_AXIS_DEFAULTS.max) - (fillAxis.min ?? CHART_AXIS_DEFAULTS.min)
17839
+ );
17840
+ const renderColumn = (picto, orient = "vertical") => {
17841
+ const currentValue = resolveItemValue(picto.id, picto.value, values);
17842
+ const vStatus = picto.answer !== void 0 ? validationDetails?.[picto.id] : void 0;
17843
+ const validationStatus = vStatus?.isCorrect === true ? "correct" : vStatus?.isCorrect === false ? "incorrect" : null;
17844
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bar-wrapper", children: /* @__PURE__ */ jsxRuntime.jsx(
17845
+ PictographColumn,
17846
+ {
17847
+ picto,
17848
+ currentValue,
17849
+ axis: fillAxis,
17850
+ validationStatus,
17851
+ orientation: orient,
17852
+ onChange
17853
+ }
17854
+ ) }, picto.id);
17855
+ };
17856
+ if (isHorizontal) {
17857
+ const chartStyle2 = computePictographChartStyle(item.scale, pictos.length, totalSlots, "horizontal");
17858
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-container", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-scroll", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: chartClass, dir: "ltr", style: chartStyle2, children: [
17859
+ item.title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-title", children: item.title }),
17860
+ categoryAxis?.visible && categoryAxis.title?.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--y-h", children: renderAxisTitle(categoryAxis.title) }) : null,
17861
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-y--categories", children: pictos.map((picto) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bar-y-label", children: picto.label?.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
17862
+ LayoutRenderer,
17863
+ {
17864
+ item: subItem,
17865
+ widgets,
17866
+ values,
17867
+ droppedInputs: EMPTY_SET,
17868
+ validationDetails,
17869
+ onChange
17870
+ },
17871
+ subItem.id
17872
+ )) }, picto.id)) }),
17873
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "chart-plot", children: [
17874
+ grid && /* @__PURE__ */ jsxRuntime.jsx(PictographGrid, { axis: fillAxis, columnCount: pictos.length, orientation: "horizontal" }),
17875
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bars", children: pictos.map((p) => renderColumn(p, "horizontal")) })
17876
+ ] }),
17877
+ valueAxis && valueAxis.visible && /* @__PURE__ */ jsxRuntime.jsx(XAxis, { axis: valueAxis, title: renderAxisTitle(valueAxis.title) })
17878
+ ] }) }) });
17879
+ }
17880
+ const chartStyle = computePictographChartStyle(item.scale, pictos.length, totalSlots);
17881
+ const hasAnyLabel = pictos.some((p) => p.label && p.label.length > 0);
17882
+ const xAxisRow = hasAnyLabel ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-x", children: pictos.map((picto) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-tick-x", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-tick-x-inner", children: picto.label?.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
17883
+ LayoutRenderer,
17884
+ {
17885
+ item: subItem,
17886
+ widgets,
17887
+ values,
17888
+ droppedInputs: EMPTY_SET,
17889
+ validationDetails,
17890
+ onChange
17891
+ },
17892
+ subItem.id
17893
+ )) }) }, picto.id)) }) : null;
17894
+ const xAxisTitle = categoryAxis?.visible && categoryAxis.title?.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--x-category", children: renderAxisTitle(categoryAxis.title) }) : null;
17895
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-container", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-scroll", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: chartClass, dir: "ltr", style: chartStyle, children: [
17896
+ item.title && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-title", children: item.title }),
17897
+ valueAxis?.visible && valueAxis.title?.length ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-axis-title chart-axis-title--y", children: renderAxisTitle(valueAxis.title) }) : null,
17898
+ valueAxis && valueAxis.visible && /* @__PURE__ */ jsxRuntime.jsx(YAxis, { axis: valueAxis }),
17899
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "chart-plot", children: [
17900
+ grid && /* @__PURE__ */ jsxRuntime.jsx(PictographGrid, { axis: fillAxis, columnCount: pictos.length }),
17901
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart-bars", children: pictos.map((p) => renderColumn(p)) })
17902
+ ] }),
17903
+ xAxisRow,
17904
+ xAxisTitle
17905
+ ] }) }) });
17906
+ };
17907
+ const CHART_TYPE_COMPONENTS = {
17908
+ bar: BarChart,
17909
+ pictograph: PictographChart
17910
+ // line: LineChart,
17911
+ // pie: PieChart,
17912
+ };
17913
+ const Chart = (props) => {
17914
+ const ChartComponent = CHART_TYPE_COMPONENTS[props.item.chartType];
17915
+ if (!ChartComponent) {
17916
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "chart chart--unsupported", children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { color: "var(--exercise-incorrect, #ef4444)", fontSize: "0.85rem" }, children: `Chart type "${props.item.chartType}" is not yet supported.` }) });
17917
+ }
17918
+ return /* @__PURE__ */ jsxRuntime.jsx(ChartComponent, { ...props });
17919
+ };
16336
17920
  const RadioOptions = ({
16337
17921
  name,
16338
17922
  options,
@@ -18459,11 +20043,14 @@ const NumberLineRenderer = ({
18459
20043
  onChangeRef.current = onChange;
18460
20044
  const valuesRef = React.useRef(values);
18461
20045
  valuesRef.current = values;
20046
+ const prevCounterCountRef = React.useRef(null);
18462
20047
  React.useEffect(() => {
18463
20048
  if (counter === void 0) return;
18464
20049
  const next = String(counterCount);
20050
+ const isSeed = prevCounterCountRef.current === null || prevCounterCountRef.current === counterCount;
20051
+ prevCounterCountRef.current = counterCount;
18465
20052
  if (valuesRef.current[counter.id] !== next) {
18466
- onChangeRef.current(counter.id, next);
20053
+ onChangeRef.current(counter.id, next, false, void 0, isSeed);
18467
20054
  }
18468
20055
  }, [counterCount, counter?.id]);
18469
20056
  const cursorKeys = React.useMemo(
@@ -18477,7 +20064,7 @@ const NumberLineRenderer = ({
18477
20064
  for (const cursor of cursors ?? []) {
18478
20065
  const existing = valuesRef.current[cursor.id];
18479
20066
  if (!isDefinitionChange && existing !== void 0 && existing !== "") continue;
18480
- onChangeRef.current(cursor.id, String(cursor.value));
20067
+ onChangeRef.current(cursor.id, String(cursor.value), false, void 0, true);
18481
20068
  }
18482
20069
  }, [cursorKeys]);
18483
20070
  const effectiveStep = React.useMemo(() => {
@@ -19219,6 +20806,20 @@ const LayoutRenderer = ({
19219
20806
  }
19220
20807
  );
19221
20808
  }
20809
+ case JSON_TYPES.CHART: {
20810
+ const chartItem = item;
20811
+ return /* @__PURE__ */ jsxRuntime.jsx(
20812
+ Chart,
20813
+ {
20814
+ item: chartItem,
20815
+ widgets,
20816
+ values,
20817
+ validationDetails,
20818
+ onChange: (barId, val) => onChange(barId, val)
20819
+ },
20820
+ resetKey
20821
+ );
20822
+ }
19222
20823
  default:
19223
20824
  return null;
19224
20825
  }
@@ -19232,18 +20833,30 @@ const FLIP_BTN_EXTRA = 44;
19232
20833
  function buildInitialValues(json) {
19233
20834
  const initial = {};
19234
20835
  for (const [id, widget] of Object.entries(json.widgets)) {
19235
- if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
19236
- if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
20836
+ if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) {
20837
+ initial[id] = [];
20838
+ } else if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
19237
20839
  initial[id] = widget.cells.filter((cell) => isCellInInteractiveArea(
19238
20840
  cell,
19239
20841
  widget.rows,
19240
20842
  widget.cols,
19241
20843
  widget.interactiveArea
19242
20844
  ));
20845
+ } else if (widget.type === JSON_TYPES.CHART_BAR || widget.type === JSON_TYPES.CHART_PICTO) {
20846
+ initial[id] = String(widget.value);
19243
20847
  }
19244
20848
  }
19245
20849
  return initial;
19246
20850
  }
20851
+ function toBaseId(widgetId) {
20852
+ return parseFractionKey(widgetId)?.widgetId ?? widgetId;
20853
+ }
20854
+ function buildInitialValuesSignature(json) {
20855
+ return JSON.stringify({
20856
+ widgets: json.widgets,
20857
+ answers: json.validation?.answers ?? null
20858
+ });
20859
+ }
19247
20860
  function buildRevealedValues(json) {
19248
20861
  const revealed = {};
19249
20862
  const validation = json.validation;
@@ -19291,9 +20904,12 @@ const ExerciseRenderer = React.forwardRef(({
19291
20904
  soundEnabled = true,
19292
20905
  sounds
19293
20906
  }, ref) => {
20907
+ const initialValuesSignature = React.useMemo(() => buildInitialValuesSignature(json), [json]);
19294
20908
  const [values, setValues] = React.useState(() => buildInitialValues(json));
19295
20909
  const valuesRef = React.useRef(values);
19296
20910
  valuesRef.current = values;
20911
+ const touchedRef = React.useRef(/* @__PURE__ */ new Set());
20912
+ const [loadedValuesSignature, setLoadedValuesSignature] = React.useState(initialValuesSignature);
19297
20913
  const [droppedSources, setDroppedSources] = React.useState(/* @__PURE__ */ new Map());
19298
20914
  const [focusedInputId, setFocusedInputId] = React.useState(null);
19299
20915
  const [validationDetails, setValidationDetails] = React.useState(null);
@@ -19302,6 +20918,16 @@ const ExerciseRenderer = React.forwardRef(({
19302
20918
  const [revealed, setRevealed] = React.useState(false);
19303
20919
  const playSound = useSoundPlayer(soundEnabled, sounds);
19304
20920
  const externallyLocked = useExerciseLocked();
20921
+ React.useLayoutEffect(() => {
20922
+ if (loadedValuesSignature === initialValuesSignature) return;
20923
+ setValues(buildInitialValues(json));
20924
+ setDroppedSources(/* @__PURE__ */ new Map());
20925
+ setFocusedInputId(null);
20926
+ setValidationDetails(null);
20927
+ setResetKey((k) => k + 1);
20928
+ setLoadedValuesSignature(initialValuesSignature);
20929
+ touchedRef.current = /* @__PURE__ */ new Set();
20930
+ }, [json, initialValuesSignature, loadedValuesSignature]);
19305
20931
  const locked = validationDetails !== null || externallyLocked;
19306
20932
  const lastResultRef = React.useRef(null);
19307
20933
  const runSubmit = () => {
@@ -19339,8 +20965,11 @@ const ExerciseRenderer = React.forwardRef(({
19339
20965
  getValues: () => values,
19340
20966
  getCompletion: () => {
19341
20967
  const answerable = getAnswerableInputs(json);
19342
- const missing = getMissingInputs(json, valuesRef.current);
19343
- const missingSet = new Set(missing);
20968
+ const missingSet = new Set(getMissingInputs(json, valuesRef.current));
20969
+ for (const id of answerable) {
20970
+ if (!touchedRef.current.has(id)) missingSet.add(id);
20971
+ }
20972
+ const missing = [...missingSet];
19344
20973
  const filledIds = answerable.filter((id) => !missingSet.has(id));
19345
20974
  return {
19346
20975
  total: answerable.length,
@@ -19360,6 +20989,8 @@ const ExerciseRenderer = React.forwardRef(({
19360
20989
  setValidationDetails(null);
19361
20990
  setResetKey((k) => k + 1);
19362
20991
  setRevealed(false);
20992
+ setLoadedValuesSignature(initialValuesSignature);
20993
+ touchedRef.current = /* @__PURE__ */ new Set();
19363
20994
  },
19364
20995
  revealAnswers: () => {
19365
20996
  const revealedValues = buildRevealedValues(json);
@@ -19368,6 +20999,7 @@ const ExerciseRenderer = React.forwardRef(({
19368
20999
  const details = {};
19369
21000
  for (const [widgetId, userValue] of Object.entries(revealedValues)) {
19370
21001
  details[widgetId] = { widgetId, userValue, isCorrect: true };
21002
+ touchedRef.current.add(toBaseId(widgetId));
19371
21003
  }
19372
21004
  const next = { ...valuesRef.current, ...revealedValues };
19373
21005
  valuesRef.current = next;
@@ -19387,10 +21019,13 @@ const ExerciseRenderer = React.forwardRef(({
19387
21019
  const target = candidates.find(canTakeInput) ?? (locked ? void 0 : candidates[0]);
19388
21020
  target?.focus();
19389
21021
  }
19390
- }), [json, values, onSubmit, playSound, locked]);
19391
- const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
21022
+ }), [json, values, onSubmit, playSound, locked, initialValuesSignature]);
21023
+ const handleChange = (widgetId, value, fromDrop = false, sourceOptionId, silent = false) => {
19392
21024
  if (locked) return;
19393
- setRevealed(false);
21025
+ if (!silent) {
21026
+ setRevealed(false);
21027
+ touchedRef.current.add(toBaseId(widgetId));
21028
+ }
19394
21029
  const next = { ...valuesRef.current, [widgetId]: value };
19395
21030
  valuesRef.current = next;
19396
21031
  setValues(next);
@@ -19477,6 +21112,22 @@ const ExerciseRenderer = React.forwardRef(({
19477
21112
  });
19478
21113
  continue;
19479
21114
  }
21115
+ if (item.type === JSON_TYPES.CHART) {
21116
+ if (currentGroup.length > 0) {
21117
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
21118
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
21119
+ const subGroups = groupLineItems(currentGroup);
21120
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
21121
+ currentGroup = [];
21122
+ }
21123
+ groups.push({
21124
+ items: [item],
21125
+ hasArabic: false,
21126
+ hasRTLInlineWidget: false,
21127
+ subGroups: []
21128
+ });
21129
+ continue;
21130
+ }
19480
21131
  if (item.type === JSON_TYPES.LINEBREAK) {
19481
21132
  if (currentGroup.length > 0) {
19482
21133
  const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
@@ -19659,10 +21310,24 @@ function compileWithAST(dsl) {
19659
21310
  exports.AST_TYPES = AST_TYPES;
19660
21311
  exports.BoldTextInlineSchema = BoldTextInlineSchema;
19661
21312
  exports.BoldTextSchema = BoldTextSchema;
21313
+ exports.CHART_AXIS_DEFAULTS = CHART_AXIS_DEFAULTS;
21314
+ exports.CHART_MAX_AXIS_TICKS = CHART_MAX_AXIS_TICKS;
21315
+ exports.CHART_MAX_PICTO_SLOTS = CHART_MAX_PICTO_SLOTS;
21316
+ exports.CHART_TYPES = CHART_TYPES;
19662
21317
  exports.CLOCK_FORMATS = CLOCK_FORMATS;
19663
21318
  exports.CLOCK_MODES = CLOCK_MODES;
19664
21319
  exports.CLOCK_TIME_REGEX = CLOCK_TIME_REGEX;
19665
21320
  exports.CLOCK_TYPES = CLOCK_TYPES;
21321
+ exports.ChartAxisJSONSchema = ChartAxisJSONSchema;
21322
+ exports.ChartAxisSchema = ChartAxisSchema;
21323
+ exports.ChartBarJSONSchema = ChartBarJSONSchema;
21324
+ exports.ChartBarSchema = ChartBarSchema;
21325
+ exports.ChartBarWidgetJSONSchema = ChartBarWidgetJSONSchema;
21326
+ exports.ChartBlockSchema = ChartBlockSchema;
21327
+ exports.ChartItemSchema = ChartItemSchema;
21328
+ exports.ChartPictoJSONSchema = ChartPictoJSONSchema;
21329
+ exports.ChartPictoSchema = ChartPictoSchema;
21330
+ exports.ChartPictoWidgetJSONSchema = ChartPictoWidgetJSONSchema;
19666
21331
  exports.ClockInlineSchema = ClockInlineSchema;
19667
21332
  exports.ClockItemSchema = ClockItemSchema;
19668
21333
  exports.ColorTextInlineSchema = ColorTextInlineSchema;
@@ -19779,6 +21444,10 @@ exports.compileWithAST = compileWithAST;
19779
21444
  exports.computeGridSymmetryCellsToFill = computeGridSymmetryCellsToFill;
19780
21445
  exports.computeGridSymmetryExpectedCells = computeGridSymmetryExpectedCells;
19781
21446
  exports.createBoldText = createBoldText;
21447
+ exports.createChartAxis = createChartAxis;
21448
+ exports.createChartBar = createChartBar;
21449
+ exports.createChartBlock = createChartBlock;
21450
+ exports.createChartPicto = createChartPicto;
19782
21451
  exports.createColorText = createColorText;
19783
21452
  exports.createDisplayClock = createDisplayClock;
19784
21453
  exports.createDragOptions = createDragOptions;
@@ -19826,6 +21495,13 @@ exports.getFractionKey = getFractionKey;
19826
21495
  exports.getMissingInputs = getMissingInputs;
19827
21496
  exports.isBoldText = isBoldText;
19828
21497
  exports.isCellInInteractiveArea = isCellInInteractiveArea;
21498
+ exports.isChartAxis = isChartAxis;
21499
+ exports.isChartBar = isChartBar;
21500
+ exports.isChartBarWidgetJSON = isChartBarWidgetJSON;
21501
+ exports.isChartBlock = isChartBlock;
21502
+ exports.isChartItem = isChartItem;
21503
+ exports.isChartPicto = isChartPicto;
21504
+ exports.isChartPictoWidgetJSON = isChartPictoWidgetJSON;
19829
21505
  exports.isClock = isClock;
19830
21506
  exports.isClockItem = isClockItem;
19831
21507
  exports.isColorText = isColorText;
@@ -19893,6 +21569,7 @@ exports.parseFractionKey = parseFractionKey;
19893
21569
  exports.parseSafe = parseSafe;
19894
21570
  exports.reflectGridSymmetryCell = reflectGridSymmetryCell;
19895
21571
  exports.renderExercise = renderExercise;
21572
+ exports.resolveChartAxes = resolveChartAxes;
19896
21573
  exports.toAST = toAST;
19897
21574
  exports.toJSON = toJSON;
19898
21575
  exports.tokenize = tokenize;