mviz 1.5.4 → 1.6.0

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.
@@ -4,7 +4,7 @@
4
4
  import { FONT_SIZE_XXS, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, 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 } from '../core/formatting.js';
7
+ import { inferFormat, getAxisFormatterJs, inferAxisType, resolveCurrency } from '../core/formatting.js';
8
8
  /**
9
9
  * Build ECharts options for an area chart
10
10
  */
@@ -23,7 +23,8 @@ export function buildAreaOptions(spec) {
23
23
  const firstYKey = yKeys[0] ?? 'value';
24
24
  const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
25
25
  const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
26
- const axisFormatter = getAxisFormatterJs(valueFormat);
26
+ const currency = resolveCurrency(spec.currency);
27
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
27
28
  // Detect x-axis type: time, value, or category
28
29
  const xAxisType = spec.xAxisType ?? inferAxisType(categories);
29
30
  // Get axis label config based on axis type
@@ -4,7 +4,7 @@
4
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';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
- import { inferFormat, getLabelFormatterJs, getAxisFormatterJs, inferAxisType } from '../core/formatting.js';
7
+ import { inferFormat, getLabelFormatterJs, getAxisFormatterJs, inferAxisType, resolveCurrency } from '../core/formatting.js';
8
8
  /**
9
9
  * Build ECharts options for a bar chart
10
10
  */
@@ -23,8 +23,9 @@ export function buildBarOptions(spec) {
23
23
  const firstYKey = yKeys[0] ?? 'value';
24
24
  const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
25
25
  const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
26
- const labelFormatter = getLabelFormatterJs(valueFormat);
27
- const axisFormatter = getAxisFormatterJs(valueFormat);
26
+ const currency = resolveCurrency(spec.currency);
27
+ const labelFormatter = getLabelFormatterJs(valueFormat, currency.symbol, currency.locale);
28
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
28
29
  // Detect category axis type (this is x-axis for vertical, y-axis for horizontal)
29
30
  const categoryAxisType = spec.xAxisType ?? inferAxisType(categories);
30
31
  // For horizontal bars, x-axis is always 'value'; for vertical it's the category axis type
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bubble chart generator
3
3
  */
4
- import { COLORS, FONT_SIZE_TINY, ECHARTS_CDN, FONT_STACK, getThemeColors, getPaletteWithCustom } from '../core/themes.js';
4
+ import { FONT_SIZE_TINY, ECHARTS_CDN, FONT_STACK, getThemeColors, getPaletteWithCustom } from '../core/themes.js';
5
5
  import { serializeOption } from '../core/serializer.js';
6
6
  import { registerChart, registerOptions } from './registry.js';
7
7
  /**
@@ -134,7 +134,7 @@ function generateBubble(spec) {
134
134
  background-color: ${colors.background}; font-family: ${FONT_STACK};
135
135
  }
136
136
  .container { padding: 20px; width: 100%; height: 100%; display: flex; flex-direction: column; }
137
- .red-line { width: 100%; height: 3px; background-color: ${COLORS.ERROR_RED}; margin-bottom: 12px; }
137
+ .red-line { width: 100%; height: 3px; background-color: ${colors.accent}; margin-bottom: 12px; }
138
138
  h2 { font-family: ${FONT_STACK}; color: ${colors.text}; font-size: 18px; font-weight: 900; margin: 0 0 4px 0; }
139
139
  #chart { width: 100%; flex: 1; min-height: 300px; }
140
140
  </style>
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { FONT_SIZE_XXS, BAR_MAX_WIDTH, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
- import { inferAxisType, inferFormat, getAxisFormatterJs } from '../core/formatting.js';
6
+ import { inferAxisType, inferFormat, getAxisFormatterJs, resolveCurrency } from '../core/formatting.js';
7
7
  import { registerChart, registerOptions } from './registry.js';
8
8
  /**
9
9
  * Build ECharts options for a combo chart (bar + line with optional dual axis)
@@ -23,11 +23,12 @@ export function buildComboOptions(spec) {
23
23
  // Infer format for primary y-axis (bars)
24
24
  const barSample = data.length > 0 && data[0] && barKeys[0] ? data[0][barKeys[0]] ?? 0 : 0;
25
25
  const primaryFormat = spec.format ?? inferFormat(barKeys[0] ?? 'value', barSample);
26
- const primaryAxisFormatter = getAxisFormatterJs(primaryFormat);
26
+ const currency = resolveCurrency(spec.currency);
27
+ const primaryAxisFormatter = getAxisFormatterJs(primaryFormat, currency.symbol, currency.locale);
27
28
  // Infer format for secondary y-axis (line if dual axis)
28
29
  const lineSample = data.length > 0 && data[0] && lineKeys[0] ? data[0][lineKeys[0]] ?? 0 : 0;
29
30
  const secondaryFormat = spec.secondaryFormat ?? inferFormat(lineKeys[0] ?? 'value', lineSample);
30
- const secondaryAxisFormatter = getAxisFormatterJs(secondaryFormat);
31
+ const secondaryAxisFormatter = getAxisFormatterJs(secondaryFormat, currency.symbol, currency.locale);
31
32
  // Detect x-axis type
32
33
  const xAxisType = inferAxisType(categories);
33
34
  // Get axis label config based on axis type
@@ -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 } from '../core/formatting.js';
9
+ import { inferFormat, getAxisFormatterJs, formatValue, formatNumber, resolveCurrency, localeFixed } 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,25 +50,29 @@ 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) {
54
- if (fmt === 'auto' || fmt === 'usd_auto') {
53
+ function compactFormatLabel(value, fmt, currencySymbol = '$', locale = 'en-US') {
54
+ if (fmt === 'auto' || fmt === 'currency_auto') {
55
55
  const abs = Math.abs(value);
56
- const p = fmt === 'usd_auto' ? '$' : '';
56
+ const p = fmt === 'currency_auto' ? currencySymbol : '';
57
+ const isCurrency = fmt === 'currency_auto';
58
+ const lf = isCurrency
59
+ ? (v, d) => localeFixed(v, d, locale)
60
+ : (v, d) => v.toFixed(d);
57
61
  let r;
58
62
  if (abs >= 1e9) {
59
63
  const s = abs / 1e9;
60
- r = p + (s >= 10 ? Math.round(s).toString() : s.toFixed(1)) + 'b';
64
+ r = p + (s >= 10 ? Math.round(s).toString() : lf(s, 1)) + 'b';
61
65
  }
62
66
  else if (abs >= 1e6) {
63
67
  const s = abs / 1e6;
64
- r = p + (s >= 10 ? Math.round(s).toString() : s.toFixed(1)) + 'm';
68
+ r = p + (s >= 10 ? Math.round(s).toString() : lf(s, 1)) + 'm';
65
69
  }
66
70
  else if (abs >= 1e3) {
67
71
  const s = abs / 1e3;
68
- r = p + (s >= 10 ? Math.round(s).toString() : s.toFixed(1)) + 'k';
72
+ r = p + (s >= 10 ? Math.round(s).toString() : lf(s, 1)) + 'k';
69
73
  }
70
74
  else {
71
- r = abs === Math.floor(abs) ? p + abs.toString() : p + abs.toFixed(1);
75
+ r = abs === Math.floor(abs) ? p + abs.toString() : p + lf(abs, 1);
72
76
  }
73
77
  return value < 0 ? '(' + r + ')' : r;
74
78
  }
@@ -77,8 +81,8 @@ function compactFormatLabel(value, fmt) {
77
81
  /**
78
82
  * Get the appropriate font size for a dumbbell dot label.
79
83
  */
80
- function dumbbellLabelFontSize(value, fmt) {
81
- const label = compactFormatLabel(value, fmt);
84
+ function dumbbellLabelFontSize(value, fmt, currencySymbol = '$', locale = 'en-US') {
85
+ const label = compactFormatLabel(value, fmt, currencySymbol, locale);
82
86
  return label.length >= COMPACT_LABEL_CHAR_THRESHOLD
83
87
  ? DUMBBELL_LABEL_FONT_SMALL
84
88
  : DUMBBELL_LABEL_FONT;
@@ -88,56 +92,68 @@ function dumbbellLabelFontSize(value, fmt) {
88
92
  * Dumbbell stores values as [value, categoryIndex], so we extract value from params.value[0].
89
93
  * Uses compact formatting (~4-5 chars max) to fit inside small dot symbols.
90
94
  */
91
- function buildDumbbellLabelFormatter(fmt) {
92
- if (fmt === 'auto' || fmt === 'usd_auto') {
93
- const isCurrency = fmt === 'usd_auto' ? 'true' : 'false';
95
+ function buildDumbbellLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US') {
96
+ if (fmt === 'auto' || fmt === 'currency_auto') {
97
+ const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
98
+ const symbolStr = JSON.stringify(currencySymbol);
99
+ const locStr = JSON.stringify(locale);
100
+ const lfHelper = fmt === 'currency_auto'
101
+ ? `var loc = ${locStr}; var lf = function(v, d) { return v.toLocaleString(loc, {minimumFractionDigits:d, maximumFractionDigits:d}); };`
102
+ : `var lf = function(v, d) { return v.toFixed(d); };`;
94
103
  // Compact: max 1 decimal for scaled values to fit in dot labels
95
104
  return `function(params) {
96
105
  var v = params.value[0];
97
106
  if (v == null) return '';
98
107
  var abs = Math.abs(v);
99
108
  var neg = v < 0;
100
- var p = ${isCurrency} ? '$' : '';
109
+ var p = ${isCurrency} ? ${symbolStr} : '';
110
+ ${lfHelper}
101
111
  var r;
102
112
  if (abs >= 1e9) {
103
113
  var s = abs / 1e9;
104
- r = p + (s >= 10 ? Math.round(s) : s.toFixed(1)) + 'b';
114
+ r = p + (s >= 10 ? Math.round(s) : lf(s,1)) + 'b';
105
115
  } else if (abs >= 1e6) {
106
116
  var s = abs / 1e6;
107
- r = p + (s >= 10 ? Math.round(s) : s.toFixed(1)) + 'm';
117
+ r = p + (s >= 10 ? Math.round(s) : lf(s,1)) + 'm';
108
118
  } else if (abs >= 1e3) {
109
119
  var s = abs / 1e3;
110
- r = p + (s >= 10 ? Math.round(s) : s.toFixed(1)) + 'k';
120
+ r = p + (s >= 10 ? Math.round(s) : lf(s,1)) + 'k';
111
121
  } else {
112
- r = abs === Math.floor(abs) ? p + abs : p + abs.toFixed(1);
122
+ r = abs === Math.floor(abs) ? p + abs : p + lf(abs,1);
113
123
  }
114
124
  return neg ? '(' + r + ')' : r;
115
125
  }`;
116
126
  }
117
- const expr = formatValue(fmt);
127
+ const expr = formatValue(fmt, currencySymbol, locale);
118
128
  return `function(params) { var value = params.value[0]; if (value == null) return ''; return ${expr}; }`;
119
129
  }
120
130
  /**
121
131
  * Build a tooltip value formatting expression for dumbbell charts.
122
132
  * Tooltip can be more verbose than dot labels, so uses full smart formatting.
123
133
  */
124
- function buildDumbbellTooltipExpr(fmt) {
125
- if (fmt === 'auto' || fmt === 'usd_auto') {
126
- const isCurrency = fmt === 'usd_auto' ? 'true' : 'false';
134
+ function buildDumbbellTooltipExpr(fmt, currencySymbol = '$', locale = 'en-US') {
135
+ if (fmt === 'auto' || fmt === 'currency_auto') {
136
+ const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
137
+ const symbolStr = JSON.stringify(currencySymbol);
138
+ const locStr = JSON.stringify(locale);
139
+ const lfHelper = fmt === 'currency_auto'
140
+ ? `var loc = ${locStr}; var lf = function(v, d) { return v.toLocaleString(loc, {minimumFractionDigits:d, maximumFractionDigits:d}); };`
141
+ : `var lf = function(v, d) { return v.toFixed(d); };`;
127
142
  return `(function(v) {
128
143
  var abs = Math.abs(v);
129
144
  var neg = v < 0;
130
- var p = ${isCurrency} ? '$' : '';
145
+ var p = ${isCurrency} ? ${symbolStr} : '';
146
+ ${lfHelper}
131
147
  var r;
132
- if (abs >= 1e9) { var s = abs / 1e9; r = s >= 100 ? p + s.toFixed(1) + 'b' : s >= 10 ? p + s.toFixed(2) + 'b' : p + s.toFixed(3) + 'b'; }
133
- else if (abs >= 1e6) { var s = abs / 1e6; r = s >= 100 ? p + s.toFixed(1) + 'm' : s >= 10 ? p + s.toFixed(2) + 'm' : p + s.toFixed(3) + 'm'; }
134
- else if (abs >= 1e4) { var s = abs / 1e3; r = s >= 100 ? p + s.toFixed(1) + 'k' : p + s.toFixed(2) + 'k'; }
135
- else if (abs >= 1e3) { r = p + abs.toLocaleString('en-US', {maximumFractionDigits: 0}); }
136
- else { r = abs === Math.floor(abs) ? p + abs : p + abs.toFixed(2); }
148
+ if (abs >= 1e9) { var s = abs / 1e9; r = s >= 100 ? p + lf(s,1) + 'b' : s >= 10 ? p + lf(s,2) + 'b' : p + lf(s,3) + 'b'; }
149
+ else if (abs >= 1e6) { var s = abs / 1e6; r = s >= 100 ? p + lf(s,1) + 'm' : s >= 10 ? p + lf(s,2) + 'm' : p + lf(s,3) + 'm'; }
150
+ else if (abs >= 1e4) { var s = abs / 1e3; r = s >= 100 ? p + lf(s,1) + 'k' : p + lf(s,2) + 'k'; }
151
+ else if (abs >= 1e3) { r = p + abs.toLocaleString(${locStr}, {maximumFractionDigits: 0}); }
152
+ else { r = abs === Math.floor(abs) ? p + abs : p + lf(abs,2); }
137
153
  return neg ? '(' + r + ')' : r;
138
154
  })(val)`;
139
155
  }
140
- const expr = formatValue(fmt);
156
+ const expr = formatValue(fmt, currencySymbol, locale);
141
157
  // formatValue uses 'value' as the variable name, replace with 'val' for tooltip context
142
158
  return expr.replace(/\bvalue\b/g, 'val');
143
159
  }
@@ -177,7 +193,8 @@ export function buildDumbbellOptions(spec) {
177
193
  // Infer value format
178
194
  const sampleValue = startValues[0] ?? 0;
179
195
  const valueFormat = spec.format ?? inferFormat(startField, sampleValue);
180
- const axisFormatter = getAxisFormatterJs(valueFormat);
196
+ const currency = resolveCurrency(spec.currency);
197
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
181
198
  // Calculate axis limits
182
199
  const allValues = [...startValues, ...endValues];
183
200
  let xMin = spec.xMin;
@@ -198,7 +215,7 @@ export function buildDumbbellOptions(spec) {
198
215
  }
199
216
  }
200
217
  // Value formatter for labels inside dots (uses formatValue for all format types)
201
- const valueFormatterJs = buildDumbbellLabelFormatter(valueFormat);
218
+ const valueFormatterJs = buildDumbbellLabelFormatter(valueFormat, currency.symbol, currency.locale);
202
219
  // Build line data for custom series
203
220
  const lineData = startValues.map((s, i) => {
204
221
  const e = endValues[i];
@@ -309,7 +326,7 @@ export function buildDumbbellOptions(spec) {
309
326
  label: {
310
327
  show: showValues,
311
328
  position: 'inside',
312
- fontSize: dumbbellLabelFontSize(v, valueFormat),
329
+ fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale),
313
330
  fontWeight: 'bold',
314
331
  color: rowColors[i],
315
332
  formatter: { _js_: valueFormatterJs },
@@ -332,7 +349,7 @@ export function buildDumbbellOptions(spec) {
332
349
  label: {
333
350
  show: showValues,
334
351
  position: 'inside',
335
- fontSize: dumbbellLabelFontSize(v, valueFormat),
352
+ fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale),
336
353
  fontWeight: 'bold',
337
354
  color: '#ffffff',
338
355
  formatter: { _js_: valueFormatterJs },
@@ -346,7 +363,7 @@ export function buildDumbbellOptions(spec) {
346
363
  z: 10,
347
364
  });
348
365
  // Tooltip formatter with value formatting
349
- const tooltipValueExpr = buildDumbbellTooltipExpr(valueFormat);
366
+ const tooltipValueExpr = buildDumbbellTooltipExpr(valueFormat, currency.symbol, currency.locale);
350
367
  const tooltipFormatterJs = `function(params) {
351
368
  if (params.seriesName === 'range') return '';
352
369
  var cat = ${JSON.stringify(categories)}[params.data.value ? params.data.value[1] : params.data[1]];
@@ -3,49 +3,67 @@
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 } from '../core/formatting.js';
6
+ import { inferFormat, resolveCurrency } 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
+ * @param currencySymbol - Symbol to use for currency formats
11
+ * @param locale - Locale for number formatting (defaults to 'en-US')
10
12
  */
11
- function getFunnelFormatterJs(fmt) {
13
+ function getFunnelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
12
14
  // Smart auto-formatting based on magnitude
13
- if (fmt === 'auto' || fmt === 'usd_auto' || fmt === null || fmt === undefined) {
14
- const isCurrency = fmt === 'usd_auto' ? 'true' : 'false';
15
+ if (fmt === 'auto' || fmt === 'currency_auto' || fmt === null || fmt === undefined) {
16
+ const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
17
+ const symbolStr = JSON.stringify(currencySymbol);
18
+ const locStr = JSON.stringify(locale);
19
+ const lfHelper = fmt === 'currency_auto'
20
+ ? `var loc = ${locStr}; var lf = function(v, d) { return v.toLocaleString(loc, {minimumFractionDigits:d, maximumFractionDigits:d}); };`
21
+ : `var lf = function(v, d) { return v.toFixed(d); };`;
15
22
  return {
16
23
  _js_: `function(params) {
17
24
  var v = params.value;
18
25
  if (v == null) return params.name;
19
26
  var abs = Math.abs(v);
20
27
  var neg = v < 0;
21
- var p = ${isCurrency} ? '$' : '';
28
+ var p = ${isCurrency} ? ${symbolStr} : '';
29
+ ${lfHelper}
22
30
  var r;
23
31
  if (abs >= 1e9) {
24
32
  var s = abs / 1e9;
25
- r = s >= 100 ? p + s.toFixed(1) + 'b' : s >= 10 ? p + s.toFixed(2) + 'b' : p + s.toFixed(3) + 'b';
33
+ r = s >= 100 ? p + lf(s,1) + 'b' : s >= 10 ? p + lf(s,2) + 'b' : p + lf(s,3) + 'b';
26
34
  } else if (abs >= 1e6) {
27
35
  var s = abs / 1e6;
28
- r = s >= 100 ? p + s.toFixed(1) + 'm' : s >= 10 ? p + s.toFixed(2) + 'm' : p + s.toFixed(3) + 'm';
36
+ r = s >= 100 ? p + lf(s,1) + 'm' : s >= 10 ? p + lf(s,2) + 'm' : p + lf(s,3) + 'm';
29
37
  } else if (abs >= 1e4) {
30
38
  var s = abs / 1e3;
31
- r = s >= 100 ? p + s.toFixed(1) + 'k' : p + s.toFixed(2) + 'k';
39
+ r = s >= 100 ? p + lf(s,1) + 'k' : p + lf(s,2) + 'k';
32
40
  } else if (abs >= 1e3) {
33
- r = p + abs.toLocaleString('en-US', {maximumFractionDigits: 0});
41
+ r = p + abs.toLocaleString(${locStr}, {maximumFractionDigits: 0});
34
42
  } else {
35
- r = abs === Math.floor(abs) ? p + abs : p + abs.toFixed(2);
43
+ r = abs === Math.floor(abs) ? p + abs : p + lf(abs,2);
36
44
  }
37
45
  var formatted = neg ? '(' + r + ')' : r;
38
46
  return params.name + ': ' + formatted;
39
47
  }`,
40
48
  };
41
49
  }
50
+ const sym = JSON.stringify(currencySymbol);
51
+ const locStr = JSON.stringify(locale);
42
52
  // Specific format handlers
43
53
  switch (fmt) {
44
- case 'usd':
45
- return { _js_: "function(params) { return params.name + ': $' + params.value.toLocaleString(); }" };
46
- case 'usd0k':
54
+ case 'currency':
55
+ return { _js_: `function(params) { return params.name + ': ' + ${sym} + params.value.toLocaleString(${locStr}); }` };
56
+ case 'currency0k':
47
57
  return {
48
- _js_: "function(params) { return params.name + ': $' + (params.value/1000).toFixed(0) + 'k'; }",
58
+ _js_: `function(params) { return params.name + ': ' + ${sym} + (params.value/1000).toFixed(0) + 'k'; }`,
59
+ };
60
+ case 'currency0m':
61
+ return {
62
+ _js_: `function(params) { return params.name + ': ' + ${sym} + (params.value/1000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'm'; }`,
63
+ };
64
+ case 'currency0b':
65
+ return {
66
+ _js_: `function(params) { return params.name + ': ' + ${sym} + (params.value/1000000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'b'; }`,
49
67
  };
50
68
  case 'pct':
51
69
  case 'pct1':
@@ -88,8 +106,9 @@ export function buildFunnelOptions(spec) {
88
106
  // Infer format from value field name or use explicit format
89
107
  const sampleValue = funnelData[0]?.value ?? 0;
90
108
  const valueFormat = spec.format ?? inferFormat(valueField, sampleValue);
109
+ const currency = resolveCurrency(spec.currency);
91
110
  // Get JavaScript formatter
92
- const formatter = getFunnelFormatterJs(valueFormat);
111
+ const formatter = getFunnelFormatterJs(valueFormat, currency.symbol, currency.locale);
93
112
  return {
94
113
  backgroundColor: 'transparent',
95
114
  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 } from '../core/formatting.js';
7
+ import { inferFormat, resolveCurrency } 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.
@@ -35,45 +35,55 @@ function parseFormattedValue(value) {
35
35
  /**
36
36
  * Generate a label formatter for heatmap cells.
37
37
  * Heatmap data is [x, y, value], so we access params.value[2].
38
+ * @param currencySymbol - Symbol to use for currency formats
39
+ * @param locale - Locale for number formatting (defaults to 'en-US')
38
40
  */
39
- function getHeatmapLabelFormatter(fmt) {
41
+ function getHeatmapLabelFormatter(fmt, currencySymbol = '$', locale = 'en-US') {
40
42
  if (fmt === null || fmt === undefined) {
41
43
  return null;
42
44
  }
43
45
  // Smart auto-formatting for heatmap labels
44
- if (fmt === 'auto' || fmt === 'usd_auto') {
45
- const isCurrency = fmt === 'usd_auto' ? 'true' : 'false';
46
+ if (fmt === 'auto' || fmt === 'currency_auto') {
47
+ const isCurrency = fmt === 'currency_auto' ? 'true' : 'false';
48
+ const symbolStr = JSON.stringify(currencySymbol);
49
+ const locStr = JSON.stringify(locale);
50
+ const lfHelper = fmt === 'currency_auto'
51
+ ? `var loc = ${locStr}; var lf = function(v, d) { return v.toLocaleString(loc, {minimumFractionDigits:d, maximumFractionDigits:d}); };`
52
+ : `var lf = function(v, d) { return v.toFixed(d); };`;
46
53
  return {
47
54
  _js_: `function(params) {
48
55
  var v = params.value[2];
49
56
  if (v == null) return '';
50
57
  var abs = Math.abs(v);
51
58
  var neg = v < 0;
52
- var p = ${isCurrency} ? '$' : '';
59
+ var p = ${isCurrency} ? ${symbolStr} : '';
60
+ ${lfHelper}
53
61
  var r;
54
62
  if (abs >= 1e9) {
55
63
  var s = abs / 1e9;
56
- r = s >= 100 ? p + s.toFixed(1) + 'b' : s >= 10 ? p + s.toFixed(2) + 'b' : p + s.toFixed(3) + 'b';
64
+ r = s >= 100 ? p + lf(s,1) + 'b' : s >= 10 ? p + lf(s,2) + 'b' : p + lf(s,3) + 'b';
57
65
  } else if (abs >= 1e6) {
58
66
  var s = abs / 1e6;
59
- r = s >= 100 ? p + s.toFixed(1) + 'm' : s >= 10 ? p + s.toFixed(2) + 'm' : p + s.toFixed(3) + 'm';
67
+ r = s >= 100 ? p + lf(s,1) + 'm' : s >= 10 ? p + lf(s,2) + 'm' : p + lf(s,3) + 'm';
60
68
  } else if (abs >= 1e4) {
61
69
  var s = abs / 1e3;
62
- r = s >= 100 ? p + s.toFixed(1) + 'k' : p + s.toFixed(2) + 'k';
70
+ r = s >= 100 ? p + lf(s,1) + 'k' : p + lf(s,2) + 'k';
63
71
  } else if (abs >= 1e3) {
64
- r = p + abs.toLocaleString('en-US', {maximumFractionDigits: 0});
72
+ r = p + abs.toLocaleString(${locStr}, {maximumFractionDigits: 0});
65
73
  } else {
66
- r = abs === Math.floor(abs) ? p + abs : p + abs.toFixed(2);
74
+ r = abs === Math.floor(abs) ? p + abs : p + lf(abs,2);
67
75
  }
68
76
  return neg ? '(' + r + ')' : r;
69
77
  }`,
70
78
  };
71
79
  }
72
80
  // Format-specific formatters (with null checks)
81
+ const sym = JSON.stringify(currencySymbol);
82
+ const locStr = JSON.stringify(locale);
73
83
  const formatExprs = {
74
- usd: "'$' + v.toLocaleString()",
75
- usd0k: "'$' + (v/1000).toFixed(0) + 'k'",
76
- usd0m: "'$' + (v/1000000).toFixed(1) + 'm'",
84
+ currency: `${sym} + v.toLocaleString(${locStr})`,
85
+ currency0k: `${sym} + (v/1000).toFixed(0) + 'k'`,
86
+ currency0m: `${sym} + (v/1000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'm'`,
77
87
  pct: "(v * 100).toFixed(1) + '%'",
78
88
  pct0: "(v * 100).toFixed(0) + '%'",
79
89
  pct1: "(v * 100).toFixed(1) + '%'",
@@ -149,7 +159,8 @@ export function buildHeatmapOptions(spec) {
149
159
  // Infer format for labels
150
160
  const sampleValue = values[0] ?? 0;
151
161
  const valueFormat = spec.format ?? inferFormat('value', sampleValue);
152
- const labelFormatter = getHeatmapLabelFormatter(valueFormat);
162
+ const currency = resolveCurrency(spec.currency);
163
+ const labelFormatter = getHeatmapLabelFormatter(valueFormat, currency.symbol, currency.locale);
153
164
  // Build label config
154
165
  const labelConfig = { show: true, color: colors.text };
155
166
  if (labelFormatter) {
@@ -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, inferAxisType } from '../core/formatting.js';
7
+ import { inferFormat, getAxisFormatterJs, inferAxisType, resolveCurrency } from '../core/formatting.js';
8
8
  /**
9
9
  * Build ECharts options for a line chart
10
10
  */
@@ -22,7 +22,8 @@ export function buildLineOptions(spec) {
22
22
  const firstYKey = yKeys[0] ?? 'value';
23
23
  const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
24
24
  const valueFormat = spec.format ?? inferFormat(firstYKey, sampleValue);
25
- const axisFormatter = getAxisFormatterJs(valueFormat);
25
+ const currency = resolveCurrency(spec.currency);
26
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
26
27
  // Detect x-axis type: time, value, or category
27
28
  // User can override with xAxisType in spec
28
29
  const xAxisType = spec.xAxisType ?? inferAxisType(categories);
@@ -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 } from '../core/formatting.js';
7
+ import { inferFormat, getAxisFormatterJs, formatValue, resolveCurrency } from '../core/formatting.js';
8
8
  /**
9
9
  * Build ECharts options for a scatter plot
10
10
  */
@@ -22,8 +22,9 @@ export function buildScatterOptions(spec) {
22
22
  const ySample = data.length > 0 && data[0] ? data[0][yField] ?? 0 : 0;
23
23
  const xFormat = spec.xFormat ?? inferFormat(xField, xSample);
24
24
  const yFormat = spec.yFormat ?? inferFormat(yField, ySample);
25
- const xAxisFormatter = getAxisFormatterJs(xFormat);
26
- const yAxisFormatter = getAxisFormatterJs(yFormat);
25
+ const currency = resolveCurrency(spec.currency);
26
+ const xAxisFormatter = getAxisFormatterJs(xFormat, currency.symbol, currency.locale);
27
+ const yAxisFormatter = getAxisFormatterJs(yFormat, currency.symbol, currency.locale);
27
28
  // Build series - either single series or grouped by seriesField
28
29
  let seriesList;
29
30
  let legendData = [];
@@ -68,8 +69,8 @@ export function buildScatterOptions(spec) {
68
69
  ];
69
70
  }
70
71
  // Build tooltip formatter with label support and value formatting
71
- const xFormatExpr = formatValue(xFormat);
72
- const yFormatExpr = formatValue(yFormat);
72
+ const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale);
73
+ const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale);
73
74
  const tooltipFormatterJs = `function(params) {
74
75
  var d = params.value || params.data;
75
76
  var result = '';
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { FONT_SIZE_TINY, FONT_SIZE_XXS, BAR_MAX_WIDTH, getThemeColors, getPaletteWithCustom, getWaterfallColors, getAxisLabelFormatterJs, } from '../core/themes.js';
5
5
  import { wrapHtml } from '../core/serializer.js';
6
- import { inferFormat } from '../core/formatting.js';
6
+ import { inferFormat, resolveCurrency } from '../core/formatting.js';
7
7
  import { registerChart, registerOptions } from './registry.js';
8
8
  /**
9
9
  * Build ECharts options for a waterfall chart showing cumulative effect
@@ -20,7 +20,8 @@ export function buildWaterfallOptions(spec) {
20
20
  // Infer format for y-axis
21
21
  const sampleValue = data[0]?.[valueField] ?? 0;
22
22
  const valueFormat = spec.format ?? inferFormat(valueField, sampleValue);
23
- const isCurrency = valueFormat && (valueFormat.includes('usd') || valueFormat === 'usd_auto');
23
+ const currency = resolveCurrency(spec.currency);
24
+ const isCurrency = valueFormat && (valueFormat.includes('currency') || valueFormat === 'currency_auto');
24
25
  const categories = [];
25
26
  const invisibleData = [];
26
27
  const increaseData = [];
@@ -64,30 +65,35 @@ export function buildWaterfallOptions(spec) {
64
65
  }
65
66
  }
66
67
  }
67
- // Label formatter JS function
68
+ // Label formatter JS function - use locale-aware formatting for currency
69
+ const locStr = JSON.stringify(currency.locale);
70
+ const lfHelper = isCurrency
71
+ ? `var loc = ${locStr}; var lf = function(v, d) { return v.toLocaleString(loc, {minimumFractionDigits:d, maximumFractionDigits:d}); };`
72
+ : `var lf = function(v, d) { return v.toFixed(d); };`;
68
73
  const labelFormatter = {
69
74
  _js_: `function(params) {
70
75
  var value = params.value;
71
76
  if (value === '-' || value === null || value === undefined) return '';
72
77
  var absVal = Math.abs(value);
73
78
  var isNeg = value < 0;
79
+ ${lfHelper}
74
80
  var formatted;
75
81
  if (absVal >= 1000000000) {
76
82
  var scaled = absVal / 1000000000;
77
- if (scaled >= 100) formatted = scaled.toFixed(1) + 'b';
78
- else if (scaled >= 10) formatted = scaled.toFixed(2) + 'b';
79
- else formatted = scaled.toFixed(3) + 'b';
83
+ if (scaled >= 100) formatted = lf(scaled,1) + 'b';
84
+ else if (scaled >= 10) formatted = lf(scaled,2) + 'b';
85
+ else formatted = lf(scaled,3) + 'b';
80
86
  } else if (absVal >= 1000000) {
81
87
  var scaled = absVal / 1000000;
82
- if (scaled >= 100) formatted = scaled.toFixed(1) + 'm';
83
- else if (scaled >= 10) formatted = scaled.toFixed(2) + 'm';
84
- else formatted = scaled.toFixed(3) + 'm';
88
+ if (scaled >= 100) formatted = lf(scaled,1) + 'm';
89
+ else if (scaled >= 10) formatted = lf(scaled,2) + 'm';
90
+ else formatted = lf(scaled,3) + 'm';
85
91
  } else if (absVal >= 10000) {
86
92
  var scaled = absVal / 1000;
87
- if (scaled >= 100) formatted = scaled.toFixed(1) + 'k';
88
- else formatted = scaled.toFixed(2) + 'k';
93
+ if (scaled >= 100) formatted = lf(scaled,1) + 'k';
94
+ else formatted = lf(scaled,2) + 'k';
89
95
  } else if (absVal >= 1000) {
90
- formatted = absVal.toLocaleString();
96
+ formatted = absVal.toLocaleString(${locStr});
91
97
  } else {
92
98
  formatted = absVal.toString();
93
99
  }
@@ -155,7 +161,7 @@ export function buildWaterfallOptions(spec) {
155
161
  axisLabel: {
156
162
  color: colors.textSecondary,
157
163
  fontSize: FONT_SIZE_TINY,
158
- formatter: getAxisLabelFormatterJs(!!isCurrency),
164
+ formatter: getAxisLabelFormatterJs(!!isCurrency, currency.symbol, currency.locale),
159
165
  },
160
166
  },
161
167
  series: [
@@ -7,7 +7,7 @@ import { registerComponent } from './registry.js';
7
7
  * Generate an alert/banner component
8
8
  */
9
9
  function generateAlert(spec) {
10
- const message = spec.message ?? '';
10
+ const message = spec.message ?? spec.content ?? '';
11
11
  const alertType = (spec.alertType ?? 'info');
12
12
  const title = spec.title ?? '';
13
13
  const theme = (spec.theme ?? 'light');
@@ -2,7 +2,7 @@
2
2
  * Big value display component
3
3
  */
4
4
  import { COLORS, FONT_STACK, getThemeColors } from '../core/themes.js';
5
- import { formatNumber, inferFormat } from '../core/formatting.js';
5
+ import { formatNumber, inferFormat, resolveCurrency } from '../core/formatting.js';
6
6
  import { registerComponent } from './registry.js';
7
7
  import { ValidationError } from '../core/exceptions.js';
8
8
  /**
@@ -25,8 +25,9 @@ function generateBigValue(spec) {
25
25
  const title = showTitleHeader ? specTitle : '';
26
26
  // Auto-infer format from label if not specified
27
27
  const fmt = spec.format ?? inferFormat(label, value);
28
+ const currency = resolveCurrency(spec.currency);
28
29
  const colors = getThemeColors(theme);
29
- const displayValue = formatNumber(value, fmt);
30
+ const displayValue = formatNumber(value, fmt, false, currency);
30
31
  let comparisonHtml = '';
31
32
  if (comparison) {
32
33
  const compVal = comparison.value ?? 0;
@@ -34,7 +35,7 @@ function generateBigValue(spec) {
34
35
  const isPositive = compVal >= 0;
35
36
  const arrow = isPositive ? '↑' : '↓';
36
37
  const compColor = isPositive ? COLORS.POSITIVE_GREEN : COLORS.ERROR_RED;
37
- const compDisplay = formatNumber(compVal, comparison.format, true);
38
+ const compDisplay = formatNumber(compVal, comparison.format, true, currency);
38
39
  comparisonHtml = `
39
40
  <div style="display: flex; align-items: center; gap: 4px; margin-top: 8px;">
40
41
  <span style="color: ${compColor}; font-size: 14px;">${arrow} ${compDisplay}</span>