mviz 1.6.4 → 1.6.7

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.
Files changed (47) hide show
  1. package/README.md +8 -8
  2. package/dist/charts/area.js +8 -36
  3. package/dist/charts/bar.js +8 -26
  4. package/dist/charts/bubble.js +9 -7
  5. package/dist/charts/combo.js +17 -39
  6. package/dist/charts/dumbbell.js +15 -14
  7. package/dist/charts/funnel.js +12 -7
  8. package/dist/charts/heatmap.js +8 -6
  9. package/dist/charts/line.js +6 -27
  10. package/dist/charts/scatter.js +7 -5
  11. package/dist/cli.js +4 -3
  12. package/dist/components/big_value.d.ts +23 -1
  13. package/dist/components/big_value.js +84 -25
  14. package/dist/components/delta.d.ts +24 -1
  15. package/dist/components/delta.js +63 -17
  16. package/dist/components/table-interactivity.d.ts +69 -0
  17. package/dist/components/table-interactivity.js +216 -0
  18. package/dist/components/table.d.ts +6 -1
  19. package/dist/components/table.js +53 -12
  20. package/dist/core/chart-helpers.d.ts +59 -5
  21. package/dist/core/chart-helpers.js +84 -5
  22. package/dist/core/formatting.d.ts +61 -4
  23. package/dist/core/formatting.js +216 -17
  24. package/dist/core/lint-rules/registry.d.ts +4 -2
  25. package/dist/core/lint-rules/registry.js +6 -1
  26. package/dist/core/lint-rules/rules/index.d.ts +1 -0
  27. package/dist/core/lint-rules/rules/index.js +1 -0
  28. package/dist/core/lint-rules/rules/pct-scalar-gt-one.d.ts +13 -0
  29. package/dist/core/lint-rules/rules/pct-scalar-gt-one.js +46 -0
  30. package/dist/core/lint-rules/types.d.ts +12 -0
  31. package/dist/core/linter.d.ts +10 -2
  32. package/dist/core/linter.js +60 -12
  33. package/dist/layout/block-loader.d.ts +31 -0
  34. package/dist/layout/block-loader.js +143 -0
  35. package/dist/layout/layout-resolver.d.ts +33 -0
  36. package/dist/layout/layout-resolver.js +73 -0
  37. package/dist/layout/markdown-parser.d.ts +34 -0
  38. package/dist/layout/markdown-parser.js +395 -0
  39. package/dist/layout/parser-types.d.ts +116 -0
  40. package/dist/layout/parser-types.js +11 -0
  41. package/dist/layout/parser.d.ts +31 -22
  42. package/dist/layout/parser.js +118 -1006
  43. package/dist/layout/renderer.d.ts +33 -0
  44. package/dist/layout/renderer.js +450 -0
  45. package/dist/types.d.ts +1 -1
  46. package/package.json +6 -6
  47. package/schema/mviz.v1.schema.json +402 -33
package/README.md CHANGED
@@ -156,7 +156,7 @@ title: My Report
156
156
  ## Section Name
157
157
 
158
158
  ```big_value size=[4,2]
159
- {"value": 125000, "label": "Revenue", "format": "usd0m"}
159
+ {"value": 125000, "label": "Revenue", "format": "currency0m"}
160
160
  ```
161
161
  ```delta size=[4,2]
162
162
  {"value": 0.15, "label": "vs Last Month", "format": "pct0"}
@@ -200,7 +200,7 @@ Tables support inline sparkline columns for trend visualization:
200
200
  "type": "table",
201
201
  "columns": [
202
202
  {"id": "product", "title": "Product"},
203
- {"id": "sales", "title": "Sales", "fmt": "usd"},
203
+ {"id": "sales", "title": "Sales", "fmt": "currency"},
204
204
  {"id": "trend", "title": "Trend", "type": "sparkline", "sparkType": "line"},
205
205
  {"id": "progress", "title": "Goal", "type": "sparkline", "sparkType": "pct_bar", "width": 100}
206
206
  ],
@@ -217,10 +217,10 @@ Sparkline types: `line`, `bar`, `area`, `pct_bar` (progress bar), `dumbbell` (be
217
217
  | Format | Output | Description |
218
218
  |--------|--------|-------------|
219
219
  | `auto` | 1.000m, 10.00k | **Smart auto-format (default)** |
220
- | `usd_auto` | $1.000m, $10.00k | Smart auto-format with $ |
221
- | `usd0m` | $1.2m | Millions |
222
- | `usd0k` | $125k | Compact thousands |
223
- | `usd` | $1,250,000 | Full dollars |
220
+ | `currency_auto` | $1.000m, $10.00k | Smart auto-format with currency symbol |
221
+ | `currency0m` | $1.2m | Millions |
222
+ | `currency0k` | $125k | Compact thousands |
223
+ | `currency` | $1,250,000 | Full currency |
224
224
  | `pct0` | 15% | Percentage integer |
225
225
  | `pct` | 15.0% | Percentage with decimal |
226
226
  | `pct1` | 15.0% | Percentage with 1 decimal |
@@ -234,7 +234,7 @@ Chart axes automatically detect the appropriate format based on field names:
234
234
 
235
235
  | Field Pattern | Auto Format | Example |
236
236
  |---------------|-------------|---------|
237
- | revenue, sales, price, cost, profit | `usd_auto` | $1.250m |
237
+ | revenue, sales, price, cost, profit | `currency_auto` | $1.250m |
238
238
  | pct, percent, rate, ratio | `pct` or `pct0` | 15.0% |
239
239
  | All other fields | `auto` | 1.250m |
240
240
 
@@ -375,7 +375,7 @@ The skill is automatically available when working in this project directory.
375
375
 
376
376
  ## Dependencies
377
377
 
378
- - Node.js 20+
378
+ - Node.js 24.12+
379
379
 
380
380
  ## Design Philosophy
381
381
 
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Area chart generator
3
3
  */
4
- import { FONT_SIZE_XXS, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
4
+ import { DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getAxisFormatterJs, inferAxisType, resolveCurrency } from '../core/formatting.js';
7
+ import { inferAxisType } from '../core/formatting.js';
8
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, buildMultiSeriesLegend, } from '../core/chart-helpers.js';
8
9
  /**
9
10
  * Build ECharts options for an area chart
10
11
  */
@@ -19,29 +20,12 @@ export function buildAreaOptions(spec) {
19
20
  const yKeys = Array.isArray(y) ? y : [y];
20
21
  const categories = data.map((d) => d[x]);
21
22
  const palette = getPaletteWithCustom(theme, spec.customTheme);
22
- // Infer format for value axis based on first y field name
23
- const firstYKey = yKeys[0] ?? 'value';
24
- const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
25
- const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
26
- const currency = resolveCurrency(spec.currency);
27
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
23
+ // Resolve value-axis format/currency/formatters
24
+ const { axisFormatter } = buildValueAxisFormatContext(spec, data, yKeys);
28
25
  // Detect x-axis type: time, value, or category
29
26
  const xAxisType = spec.xAxisType ?? inferAxisType(categories);
30
27
  // Get axis label config based on axis type
31
- const xAxisLabelConfig = xAxisType === 'category'
32
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
33
- : xAxisType === 'time'
34
- ? {
35
- hideOverlap: true,
36
- formatter: {
37
- _js_: `function(value) {
38
- var d = new Date(value);
39
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
40
- return months[d.getMonth()] + ' ' + d.getDate();
41
- }`,
42
- },
43
- }
44
- : { hideOverlap: true };
28
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
45
29
  const option = {
46
30
  backgroundColor: 'transparent',
47
31
  animation: false,
@@ -83,22 +67,10 @@ export function buildAreaOptions(spec) {
83
67
  series: [],
84
68
  };
85
69
  // Apply yMin/yMax
86
- if (spec.yMin !== undefined) {
87
- option.yAxis.min = spec.yMin;
88
- }
89
- if (spec.yMax !== undefined) {
90
- option.yAxis.max = spec.yMax;
91
- }
70
+ applyValueAxisRange(option.yAxis, spec);
92
71
  // Add legend for multi-series
93
72
  if (yKeys.length > 1) {
94
- option.legend = {
95
- data: yKeys,
96
- top: 0,
97
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
98
- itemWidth: LEGEND_ITEM_WIDTH,
99
- itemHeight: LEGEND_ITEM_HEIGHT,
100
- itemGap: LEGEND_ITEM_GAP,
101
- };
73
+ option.legend = buildMultiSeriesLegend(yKeys, colors);
102
74
  }
103
75
  // Build series
104
76
  for (let i = 0; i < yKeys.length; i++) {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Bar chart generator
3
3
  */
4
- import { BAR_MAX_WIDTH, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, FONT_SIZE_TINY, FONT_SIZE_XXS, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
4
+ import { BAR_MAX_WIDTH, FONT_SIZE_TINY, FONT_SIZE_XXS, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getLabelFormatterJs, getAxisFormatterJs, inferAxisType, resolveCurrency } from '../core/formatting.js';
7
+ import { inferAxisType } from '../core/formatting.js';
8
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, buildMultiSeriesLegend, } from '../core/chart-helpers.js';
8
9
  /**
9
10
  * Build ECharts options for a bar chart
10
11
  */
@@ -19,13 +20,8 @@ export function buildBarOptions(spec) {
19
20
  const yKeys = Array.isArray(y) ? y : [y];
20
21
  const categories = data.map((d) => d[x]);
21
22
  const palette = getPaletteWithCustom(theme, spec.customTheme);
22
- // Infer format for value axis based on first y field name
23
- const firstYKey = yKeys[0] ?? 'value';
24
- const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
25
- const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
26
- const currency = resolveCurrency(spec.currency);
27
- const labelFormatter = getLabelFormatterJs(valueFormat, currency.symbol, currency.locale);
28
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
23
+ // Resolve value-axis format/currency/formatters
24
+ const { axisFormatter, labelFormatter } = buildValueAxisFormatContext(spec, data, yKeys, { withLabelFormatter: true });
29
25
  // Detect category axis type (this is x-axis for vertical, y-axis for horizontal)
30
26
  const categoryAxisType = spec.xAxisType ?? inferAxisType(categories);
31
27
  // For horizontal bars, x-axis is always 'value'; for vertical it's the category axis type
@@ -33,9 +29,7 @@ export function buildBarOptions(spec) {
33
29
  // Determine if labels should be shown (based on category axis type, not x-axis for horizontal)
34
30
  const showLabels = yKeys.length === 1 && !stacked && data.length <= 12 && categoryAxisType === 'category';
35
31
  // Get axis label config based on axis type
36
- const xAxisLabelConfig = xAxisType === 'category'
37
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
38
- : { hideOverlap: true };
32
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
39
33
  const option = {
40
34
  backgroundColor: 'transparent',
41
35
  animation: false,
@@ -110,12 +104,7 @@ export function buildBarOptions(spec) {
110
104
  };
111
105
  // Apply yMin/yMax to value axis
112
106
  const valueAxis = horizontal ? 'xAxis' : 'yAxis';
113
- if (spec.yMin !== undefined) {
114
- option[valueAxis].min = spec.yMin;
115
- }
116
- if (spec.yMax !== undefined) {
117
- option[valueAxis].max = spec.yMax;
118
- }
107
+ applyValueAxisRange(option[valueAxis], spec);
119
108
  // Adjust y-axis gridlines based on x-axis category label length
120
109
  // Long category labels (rotated 45°) take up vertical space at bottom
121
110
  const chartHeight = typeof spec.height === 'number' ? spec.height : DEFAULT_CHART_HEIGHT;
@@ -136,14 +125,7 @@ export function buildBarOptions(spec) {
136
125
  }
137
126
  // Add legend for multi-series
138
127
  if (yKeys.length > 1) {
139
- option.legend = {
140
- data: yKeys,
141
- top: 0,
142
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
143
- itemWidth: LEGEND_ITEM_WIDTH,
144
- itemHeight: LEGEND_ITEM_HEIGHT,
145
- itemGap: LEGEND_ITEM_GAP,
146
- };
128
+ option.legend = buildMultiSeriesLegend(yKeys, colors);
147
129
  }
148
130
  // Use lighter label color for dark theme for better visibility
149
131
  const labelColor = theme === 'dark' ? colors.text : colors.textSecondary;
@@ -4,7 +4,7 @@
4
4
  import { FONT_SIZE_TINY, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getAxisFormatterJs, formatValue, resolveCurrency } from '../core/formatting.js';
7
+ import { inferFormat, getAxisFormatterJs, formatValue, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, } from '../core/formatting.js';
8
8
  import { buildPointLabelConfig, groupDataBySeries, buildSeriesLegend } from '../core/chart-helpers.js';
9
9
  /** Min/max bubble pixel sizes */
10
10
  const BUBBLE_SIZE_MIN = 10;
@@ -91,10 +91,10 @@ function buildBubbleSeries(data, ctx, seriesField, showLabels, palette, colors)
91
91
  /**
92
92
  * Build tooltip config for bubble chart.
93
93
  */
94
- function buildBubbleTooltip(ctx, seriesField, xFormat, yFormat, currency, colors) {
94
+ function buildBubbleTooltip(ctx, seriesField, xFormat, yFormat, currency, colors, xPctMultiply = true, yPctMultiply = true) {
95
95
  const labelIdx = ctx.labelField ? BUBBLE_LABEL_INDEX : -1;
96
- const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale);
97
- const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale);
96
+ const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale, xPctMultiply);
97
+ const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale, yPctMultiply);
98
98
  const seriesNameLine = seriesField
99
99
  ? "if (params.seriesName) result += '<strong>' + params.seriesName + '</strong><br/>';"
100
100
  : '';
@@ -157,6 +157,8 @@ export function buildBubbleOptions(spec) {
157
157
  const xFormat = spec.xFormat ?? inferFormat(xField, xSample);
158
158
  const yFormat = spec.yFormat ?? inferFormat(yField, ySample);
159
159
  const currency = resolveCurrency(spec.currency);
160
+ const xPctMultiply = shouldPctMultiply(xFormat, collectNumericFieldValues(data, [xField]));
161
+ const yPctMultiply = shouldPctMultiply(yFormat, collectNumericFieldValues(data, [yField]));
160
162
  // Build context for point construction
161
163
  const xAxis = detectAxisCategories(data, xField);
162
164
  const yAxis = detectAxisCategories(data, yField);
@@ -174,14 +176,14 @@ export function buildBubbleOptions(spec) {
174
176
  backgroundColor: 'transparent',
175
177
  animation: false,
176
178
  color: palette,
177
- tooltip: buildBubbleTooltip(ctx, seriesField, xFormat, yFormat, currency, colors),
179
+ tooltip: buildBubbleTooltip(ctx, seriesField, xFormat, yFormat, currency, colors, xPctMultiply, yPctMultiply),
178
180
  grid: {
179
181
  left: '6%', right: '8%',
180
182
  top: hasLegend ? '14%' : '12%',
181
183
  bottom: '14%', containLabel: true,
182
184
  },
183
- xAxis: buildBubbleAxis(xField, xAxis.isCategory, xAxis.categories, getAxisFormatterJs(xFormat, currency.symbol, currency.locale), 24, colors),
184
- yAxis: buildBubbleAxis(yField, yAxis.isCategory, yAxis.categories, getAxisFormatterJs(yFormat, currency.symbol, currency.locale), 32, colors),
185
+ xAxis: buildBubbleAxis(xField, xAxis.isCategory, xAxis.categories, getAxisFormatterJs(xFormat, currency.symbol, currency.locale, xPctMultiply), 24, colors),
186
+ yAxis: buildBubbleAxis(yField, yAxis.isCategory, yAxis.categories, getAxisFormatterJs(yFormat, currency.symbol, currency.locale, yPctMultiply), 32, colors),
185
187
  series: seriesList,
186
188
  };
187
189
  if (hasLegend) {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Combo chart generator (bar + line with optional dual axis)
3
3
  */
4
- import { FONT_SIZE_XXS, BAR_MAX_WIDTH, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
4
+ import { BAR_MAX_WIDTH, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
- import { inferAxisType, inferFormat, getAxisFormatterJs, resolveCurrency } from '../core/formatting.js';
6
+ import { inferAxisType, inferFormat } from '../core/formatting.js';
7
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, buildMultiSeriesLegend, } from '../core/chart-helpers.js';
7
8
  import { registerChart, registerOptions } from './registry.js';
8
9
  /**
9
10
  * Build ECharts options for a combo chart (bar + line with optional dual axis)
@@ -20,32 +21,21 @@ export function buildComboOptions(spec) {
20
21
  const barKeys = Array.isArray(barFields) ? barFields : barFields ? [barFields] : [];
21
22
  const lineKeys = Array.isArray(lineFields) ? lineFields : lineFields ? [lineFields] : [];
22
23
  const categories = data.map((d) => d[x]);
23
- // Infer format for primary y-axis (bars)
24
- const barSample = data.length > 0 && data[0] && barKeys[0] ? data[0][barKeys[0]] ?? 0 : 0;
25
- const primaryFormat = spec.format ?? inferFormat(barKeys[0] ?? 'value', barSample);
26
- const currency = resolveCurrency(spec.currency);
27
- const primaryAxisFormatter = getAxisFormatterJs(primaryFormat, currency.symbol, currency.locale);
28
- // Infer format for secondary y-axis (line if dual axis)
29
- const lineSample = data.length > 0 && data[0] && lineKeys[0] ? data[0][lineKeys[0]] ?? 0 : 0;
30
- const secondaryFormat = spec.secondaryFormat ?? inferFormat(lineKeys[0] ?? 'value', lineSample);
31
- const secondaryAxisFormatter = getAxisFormatterJs(secondaryFormat, currency.symbol, currency.locale);
24
+ // Resolve format/currency/formatters for primary y-axis (bars) and secondary (line).
25
+ //
26
+ // The secondary axis must NOT inherit spec.format (which is the primary/bar
27
+ // format), so we resolve it explicitly here from spec.secondaryFormat or by
28
+ // inferring from the line field, then pass it as an explicit override.
29
+ const { axisFormatter: primaryAxisFormatter } = buildValueAxisFormatContext(spec, data, barKeys);
30
+ const lineSample = data.length > 0 && data[0] && lineKeys[0]
31
+ ? data[0][lineKeys[0]] ?? 0
32
+ : 0;
33
+ const resolvedSecondaryFormat = spec.secondaryFormat ?? inferFormat(lineKeys[0] ?? 'value', lineSample);
34
+ const { axisFormatter: secondaryAxisFormatter } = buildValueAxisFormatContext(spec, data, lineKeys, { formatOverride: resolvedSecondaryFormat });
32
35
  // Detect x-axis type
33
36
  const xAxisType = inferAxisType(categories);
34
37
  // Get axis label config based on axis type
35
- const xAxisLabelConfig = xAxisType === 'category'
36
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
37
- : xAxisType === 'time'
38
- ? {
39
- hideOverlap: true,
40
- formatter: {
41
- _js_: `function(value) {
42
- var d = new Date(value);
43
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
44
- return months[d.getMonth()] + ' ' + d.getDate();
45
- }`,
46
- },
47
- }
48
- : { hideOverlap: true };
38
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
49
39
  const series = [];
50
40
  // Add bar series
51
41
  barKeys.forEach((key, i) => {
@@ -94,12 +84,7 @@ export function buildComboOptions(spec) {
94
84
  },
95
85
  ];
96
86
  // Apply yMin/yMax to primary y-axis
97
- if (spec.yMin !== undefined) {
98
- yAxis[0].min = spec.yMin;
99
- }
100
- if (spec.yMax !== undefined) {
101
- yAxis[0].max = spec.yMax;
102
- }
87
+ applyValueAxisRange(yAxis[0], spec);
103
88
  if (dualAxis) {
104
89
  yAxis.push({
105
90
  type: 'value',
@@ -128,14 +113,7 @@ export function buildComboOptions(spec) {
128
113
  bottom: '8%',
129
114
  containLabel: true,
130
115
  },
131
- legend: {
132
- data: [...barKeys, ...lineKeys],
133
- top: 0,
134
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
135
- itemWidth: LEGEND_ITEM_WIDTH,
136
- itemHeight: LEGEND_ITEM_HEIGHT,
137
- itemGap: LEGEND_ITEM_GAP,
138
- },
116
+ legend: buildMultiSeriesLegend([...barKeys, ...lineKeys], colors),
139
117
  xAxis: {
140
118
  type: xAxisType,
141
119
  data: xAxisType === 'category' ? categories.map(String) : undefined,
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { COLORS, FONT_SIZE_TINY, DUMBBELL_SYMBOL_SIZE, DEFAULT_CHART_HEIGHT, getThemeColors, } from '../core/themes.js';
8
8
  import { wrapHtml } from '../core/serializer.js';
9
- import { inferFormat, getAxisFormatterJs, formatValue, formatNumber, resolveCurrency, localeFixed } from '../core/formatting.js';
9
+ import { inferFormat, getAxisFormatterJs, formatValue, formatNumber, resolveCurrency, localeFixed, shouldPctMultiply, } from '../core/formatting.js';
10
10
  import { registerChart, registerOptions } from './registry.js';
11
11
  // Directional colors
12
12
  const POSITIVE_COLOR = COLORS.POSITIVE_GREEN;
@@ -50,7 +50,7 @@ const COMPACT_LABEL_CHAR_THRESHOLD = 5;
50
50
  * Compute the compact dumbbell label string for a given value.
51
51
  * Mirrors the JS formatter logic so we can measure character count at build time.
52
52
  */
53
- function compactFormatLabel(value, fmt, currencySymbol = '$', locale = 'en-US') {
53
+ function compactFormatLabel(value, fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
54
54
  if (fmt === 'auto' || fmt === 'currency_auto') {
55
55
  const abs = Math.abs(value);
56
56
  const p = fmt === 'currency_auto' ? currencySymbol : '';
@@ -76,13 +76,13 @@ function compactFormatLabel(value, fmt, currencySymbol = '$', locale = 'en-US')
76
76
  }
77
77
  return value < 0 ? '(' + r + ')' : r;
78
78
  }
79
- return formatNumber(value, fmt);
79
+ return formatNumber(value, fmt, false, undefined, pctMultiply);
80
80
  }
81
81
  /**
82
82
  * Get the appropriate font size for a dumbbell dot label.
83
83
  */
84
- function dumbbellLabelFontSize(value, fmt, currencySymbol = '$', locale = 'en-US') {
85
- const label = compactFormatLabel(value, fmt, currencySymbol, locale);
84
+ function dumbbellLabelFontSize(value, fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
85
+ const label = compactFormatLabel(value, fmt, currencySymbol, locale, pctMultiply);
86
86
  return label.length >= COMPACT_LABEL_CHAR_THRESHOLD
87
87
  ? DUMBBELL_LABEL_FONT_SMALL
88
88
  : DUMBBELL_LABEL_FONT;
@@ -92,7 +92,7 @@ function dumbbellLabelFontSize(value, fmt, currencySymbol = '$', locale = 'en-US
92
92
  * Dumbbell stores values as [value, categoryIndex], so we extract value from params.value[0].
93
93
  * Uses compact formatting (~4-5 chars max) to fit inside small dot symbols.
94
94
  */
95
- function buildDumbbellLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US') {
95
+ function buildDumbbellLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
96
96
  if (fmt === 'auto' || fmt === 'currency_auto') {
97
97
  const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
98
98
  const symbolStr = JSON.stringify(currencySymbol);
@@ -124,14 +124,14 @@ function buildDumbbellLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US'
124
124
  return neg ? '(' + r + ')' : r;
125
125
  }`;
126
126
  }
127
- const expr = formatValue(fmt, currencySymbol, locale);
127
+ const expr = formatValue(fmt, currencySymbol, locale, pctMultiply);
128
128
  return `function(params) { var value = params.value[0]; if (value == null) return ''; return ${expr}; }`;
129
129
  }
130
130
  /**
131
131
  * Build a tooltip value formatting expression for dumbbell charts.
132
132
  * Tooltip can be more verbose than dot labels, so uses full smart formatting.
133
133
  */
134
- function buildDumbbellTooltipExpr(fmt, currencySymbol = '$', locale = 'en-US') {
134
+ function buildDumbbellTooltipExpr(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
135
135
  if (fmt === 'auto' || fmt === 'currency_auto') {
136
136
  const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
137
137
  const symbolStr = JSON.stringify(currencySymbol);
@@ -153,7 +153,7 @@ function buildDumbbellTooltipExpr(fmt, currencySymbol = '$', locale = 'en-US') {
153
153
  return neg ? '(' + r + ')' : r;
154
154
  })(val)`;
155
155
  }
156
- const expr = formatValue(fmt, currencySymbol, locale);
156
+ const expr = formatValue(fmt, currencySymbol, locale, pctMultiply);
157
157
  // formatValue uses 'value' as the variable name, replace with 'val' for tooltip context
158
158
  return expr.replace(/\bvalue\b/g, 'val');
159
159
  }
@@ -194,7 +194,8 @@ export function buildDumbbellOptions(spec) {
194
194
  const sampleValue = startValues[0] ?? 0;
195
195
  const valueFormat = spec.format ?? inferFormat(startField, sampleValue);
196
196
  const currency = resolveCurrency(spec.currency);
197
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
197
+ const pctMultiply = shouldPctMultiply(valueFormat, [...startValues, ...endValues]);
198
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
198
199
  // Calculate axis limits
199
200
  const allValues = [...startValues, ...endValues];
200
201
  let xMin = spec.xMin;
@@ -215,7 +216,7 @@ export function buildDumbbellOptions(spec) {
215
216
  }
216
217
  }
217
218
  // Value formatter for labels inside dots (uses formatValue for all format types)
218
- const valueFormatterJs = buildDumbbellLabelFormatter(valueFormat, currency.symbol, currency.locale);
219
+ const valueFormatterJs = buildDumbbellLabelFormatter(valueFormat, currency.symbol, currency.locale, pctMultiply);
219
220
  // Build line data for custom series
220
221
  const lineData = startValues.map((s, i) => {
221
222
  const e = endValues[i];
@@ -326,7 +327,7 @@ export function buildDumbbellOptions(spec) {
326
327
  label: {
327
328
  show: showValues,
328
329
  position: 'inside',
329
- fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale),
330
+ fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale, pctMultiply),
330
331
  fontWeight: 'bold',
331
332
  color: rowColors[i],
332
333
  formatter: { _js_: valueFormatterJs },
@@ -349,7 +350,7 @@ export function buildDumbbellOptions(spec) {
349
350
  label: {
350
351
  show: showValues,
351
352
  position: 'inside',
352
- fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale),
353
+ fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale, pctMultiply),
353
354
  fontWeight: 'bold',
354
355
  color: '#ffffff',
355
356
  formatter: { _js_: valueFormatterJs },
@@ -363,7 +364,7 @@ export function buildDumbbellOptions(spec) {
363
364
  z: 10,
364
365
  });
365
366
  // Tooltip formatter with value formatting
366
- const tooltipValueExpr = buildDumbbellTooltipExpr(valueFormat, currency.symbol, currency.locale);
367
+ const tooltipValueExpr = buildDumbbellTooltipExpr(valueFormat, currency.symbol, currency.locale, pctMultiply);
367
368
  const tooltipFormatterJs = `function(params) {
368
369
  if (params.seriesName === 'range') return '';
369
370
  var cat = ${JSON.stringify(categories)}[params.data.value ? params.data.value[1] : params.data[1]];
@@ -3,14 +3,14 @@
3
3
  */
4
4
  import { FONT_SIZE_XXS, getThemeColors, getPaletteWithCustom } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
- import { inferFormat, resolveCurrency } from '../core/formatting.js';
6
+ import { inferFormat, resolveCurrency, shouldPctMultiply } from '../core/formatting.js';
7
7
  import { registerChart, registerOptions } from './registry.js';
8
8
  /**
9
9
  * Get JavaScript formatter for funnel chart labels/tooltips
10
10
  * @param currencySymbol - Symbol to use for currency formats
11
11
  * @param locale - Locale for number formatting (defaults to 'en-US')
12
12
  */
13
- function getFunnelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
13
+ function getFunnelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
14
14
  // Smart auto-formatting based on magnitude
15
15
  if (fmt === 'auto' || fmt === 'currency_auto' || fmt === null || fmt === undefined) {
16
16
  const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
@@ -66,14 +66,18 @@ function getFunnelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
66
66
  _js_: `function(params) { return params.name + ': ' + ${sym} + (params.value/1000000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'b'; }`,
67
67
  };
68
68
  case 'pct':
69
- case 'pct1':
69
+ case 'pct1': {
70
+ const scaleExpr = pctMultiply ? '(params.value * 100)' : 'params.value';
70
71
  return {
71
- _js_: "function(params) { return params.name + ': ' + (params.value * 100).toFixed(1) + '%'; }",
72
+ _js_: `function(params) { return params.name + ': ' + ${scaleExpr}.toFixed(1) + '%'; }`,
72
73
  };
73
- case 'pct0':
74
+ }
75
+ case 'pct0': {
76
+ const scaleExpr = pctMultiply ? '(params.value * 100)' : 'params.value';
74
77
  return {
75
- _js_: "function(params) { return params.name + ': ' + (params.value * 100).toFixed(0) + '%'; }",
78
+ _js_: `function(params) { return params.name + ': ' + ${scaleExpr}.toFixed(0) + '%'; }`,
76
79
  };
80
+ }
77
81
  case 'num0':
78
82
  return { _js_: "function(params) { return params.name + ': ' + params.value.toLocaleString(); }" };
79
83
  case 'num1':
@@ -107,8 +111,9 @@ export function buildFunnelOptions(spec) {
107
111
  const sampleValue = funnelData[0]?.value ?? 0;
108
112
  const valueFormat = spec.format ?? inferFormat(valueField, sampleValue);
109
113
  const currency = resolveCurrency(spec.currency);
114
+ const pctMultiply = shouldPctMultiply(valueFormat, funnelData.map((d) => d.value));
110
115
  // Get JavaScript formatter
111
- const formatter = getFunnelFormatterJs(valueFormat, currency.symbol, currency.locale);
116
+ const formatter = getFunnelFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
112
117
  return {
113
118
  backgroundColor: 'transparent',
114
119
  animation: false,
@@ -4,7 +4,7 @@
4
4
  import { FONT_SIZE_XXS, getThemeColors, getPaletteWithCustom } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, resolveCurrency } from '../core/formatting.js';
7
+ import { inferFormat, resolveCurrency, shouldPctMultiply } from '../core/formatting.js';
8
8
  /**
9
9
  * Parse a formatted string value like "1.9k" or "$2.5m" to a number.
10
10
  * Returns the original value if parsing fails.
@@ -38,7 +38,7 @@ function parseFormattedValue(value) {
38
38
  * @param currencySymbol - Symbol to use for currency formats
39
39
  * @param locale - Locale for number formatting (defaults to 'en-US')
40
40
  */
41
- function getHeatmapLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US') {
41
+ function getHeatmapLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
42
42
  if (fmt === null || fmt === undefined) {
43
43
  return null;
44
44
  }
@@ -80,13 +80,14 @@ function getHeatmapLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US') {
80
80
  // Format-specific formatters (with null checks)
81
81
  const sym = JSON.stringify(currencySymbol);
82
82
  const locStr = JSON.stringify(locale);
83
+ const pctExpr = pctMultiply ? '(v * 100)' : 'v';
83
84
  const formatExprs = {
84
85
  currency: `${sym} + v.toLocaleString(${locStr})`,
85
86
  currency0k: `${sym} + (v/1000).toFixed(0) + 'k'`,
86
87
  currency0m: `${sym} + (v/1000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'm'`,
87
- pct: "(v * 100).toFixed(1) + '%'",
88
- pct0: "(v * 100).toFixed(0) + '%'",
89
- pct1: "(v * 100).toFixed(1) + '%'",
88
+ pct: `${pctExpr}.toFixed(1) + '%'`,
89
+ pct0: `${pctExpr}.toFixed(0) + '%'`,
90
+ pct1: `${pctExpr}.toFixed(1) + '%'`,
90
91
  num0: "v.toLocaleString()",
91
92
  num1: "v.toFixed(1)",
92
93
  num0k: "(v/1000).toFixed(0) + 'k'",
@@ -160,7 +161,8 @@ export function buildHeatmapOptions(spec) {
160
161
  const sampleValue = values[0] ?? 0;
161
162
  const valueFormat = spec.format ?? inferFormat('value', sampleValue);
162
163
  const currency = resolveCurrency(spec.currency);
163
- const labelFormatter = getHeatmapLabelFormatter(valueFormat, currency.symbol, currency.locale);
164
+ const pctMultiply = shouldPctMultiply(valueFormat, values);
165
+ const labelFormatter = getHeatmapLabelFormatter(valueFormat, currency.symbol, currency.locale, pctMultiply);
164
166
  // Build label config
165
167
  const labelConfig = { show: true, color: colors.text };
166
168
  if (labelFormatter) {
@@ -4,7 +4,8 @@
4
4
  import { FONT_SIZE_TINY, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getAxisFormatterJs, inferAxisType, resolveCurrency } from '../core/formatting.js';
7
+ import { inferAxisType } from '../core/formatting.js';
8
+ import { buildValueAxisFormatContext, buildXAxisLabelConfig, applyValueAxisRange, } from '../core/chart-helpers.js';
8
9
  /**
9
10
  * Build ECharts options for a line chart
10
11
  */
@@ -18,32 +19,15 @@ export function buildLineOptions(spec) {
18
19
  const yKeys = Array.isArray(y) ? y : [y];
19
20
  const categories = data.map((d) => d[x]);
20
21
  const palette = getPaletteWithCustom(theme, spec.customTheme);
21
- // Infer format for value axis based on first y field name
22
- const firstYKey = yKeys[0] ?? 'value';
23
- const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
24
- const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
25
- const currency = resolveCurrency(spec.currency);
26
- const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
22
+ // Resolve value-axis format/currency/formatters
23
+ const { axisFormatter } = buildValueAxisFormatContext(spec, data, yKeys);
27
24
  // Detect x-axis type: time, value, or category
28
25
  // User can override with xAxisType in spec
29
26
  const xAxisType = spec.xAxisType ?? inferAxisType(categories);
30
27
  // More right margin needed for end labels on multi-series
31
28
  const hasEndLabels = yKeys.length > 1;
32
29
  // Get axis label config based on axis type
33
- const xAxisLabelConfig = xAxisType === 'category'
34
- ? { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 }
35
- : xAxisType === 'time'
36
- ? {
37
- hideOverlap: true,
38
- formatter: {
39
- _js_: `function(value) {
40
- var d = new Date(value);
41
- var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
42
- return months[d.getMonth()] + ' ' + d.getDate();
43
- }`,
44
- },
45
- }
46
- : { hideOverlap: true };
30
+ const xAxisLabelConfig = buildXAxisLabelConfig(xAxisType);
47
31
  const option = {
48
32
  backgroundColor: 'transparent',
49
33
  animation: false,
@@ -86,12 +70,7 @@ export function buildLineOptions(spec) {
86
70
  series: [],
87
71
  };
88
72
  // Apply yMin/yMax
89
- if (spec.yMin !== undefined) {
90
- option.yAxis.min = spec.yMin;
91
- }
92
- if (spec.yMax !== undefined) {
93
- option.yAxis.max = spec.yMax;
94
- }
73
+ applyValueAxisRange(option.yAxis, spec);
95
74
  // Build series
96
75
  for (let i = 0; i < yKeys.length; i++) {
97
76
  const key = yKeys[i];
@@ -4,7 +4,7 @@
4
4
  import { SCATTER_SYMBOL_SIZE, FONT_SIZE_TINY, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getAxisFormatterJs, formatValue, resolveCurrency } from '../core/formatting.js';
7
+ import { inferFormat, getAxisFormatterJs, formatValue, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, } from '../core/formatting.js';
8
8
  import { buildPointLabelConfig, groupDataBySeries, buildSeriesLegend } from '../core/chart-helpers.js';
9
9
  /** Scatter point opacity */
10
10
  const SCATTER_OPACITY = 0.75;
@@ -38,8 +38,10 @@ export function buildScatterOptions(spec) {
38
38
  const xFormat = spec.xFormat ?? inferFormat(xField, xSample);
39
39
  const yFormat = spec.yFormat ?? inferFormat(yField, ySample);
40
40
  const currency = resolveCurrency(spec.currency);
41
- const xAxisFormatter = getAxisFormatterJs(xFormat, currency.symbol, currency.locale);
42
- const yAxisFormatter = getAxisFormatterJs(yFormat, currency.symbol, currency.locale);
41
+ const xPctMultiply = shouldPctMultiply(xFormat, collectNumericFieldValues(data, [xField]));
42
+ const yPctMultiply = shouldPctMultiply(yFormat, collectNumericFieldValues(data, [yField]));
43
+ const xAxisFormatter = getAxisFormatterJs(xFormat, currency.symbol, currency.locale, xPctMultiply);
44
+ const yAxisFormatter = getAxisFormatterJs(yFormat, currency.symbol, currency.locale, yPctMultiply);
43
45
  // Label config for persistent point labels
44
46
  const labelConfig = showLabels && labelField
45
47
  ? buildPointLabelConfig(SCATTER_LABEL_INDEX, colors)
@@ -77,8 +79,8 @@ export function buildScatterOptions(spec) {
77
79
  seriesList = [seriesObj];
78
80
  }
79
81
  // Build tooltip formatter with label support and value formatting
80
- const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale);
81
- const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale);
82
+ const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale, xPctMultiply);
83
+ const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale, yPctMultiply);
82
84
  const tooltipFormatterJs = `function(params) {
83
85
  var d = params.value || params.data;
84
86
  var result = '';