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