mviz 1.5.3 → 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,8 @@
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
+ import { serializeOption } from '../core/serializer.js';
5
6
  import { registerChart, registerOptions } from './registry.js';
6
7
  /**
7
8
  * Build ECharts options for a bubble chart
@@ -11,6 +12,7 @@ export function buildBubbleOptions(spec) {
11
12
  const xField = spec.x ?? 'x';
12
13
  const yField = spec.y ?? 'y';
13
14
  const sizeField = spec.size ?? 'size';
15
+ const labelField = spec.label;
14
16
  const theme = (spec.theme ?? 'light');
15
17
  const colors = getThemeColors(theme);
16
18
  const palette = getPaletteWithCustom(theme, spec.customTheme);
@@ -40,8 +42,22 @@ export function buildBubbleOptions(spec) {
40
42
  const scaledSize = 10 + ((s - minSize) / sizeRange) * 40;
41
43
  const xVal = xIsCategory ? xCategories.indexOf(String(d[xField] ?? '')) : (d[xField] ?? 0);
42
44
  const yVal = yIsCategory ? yCategories.indexOf(String(d[yField] ?? '')) : (d[yField] ?? 0);
43
- return [xVal, yVal, s, scaledSize];
45
+ const point = [xVal, yVal, s, scaledSize];
46
+ if (labelField)
47
+ point.push(String(d[labelField] ?? ''));
48
+ return point;
44
49
  });
50
+ // Build tooltip formatter with label support
51
+ const labelIdx = labelField ? 4 : -1;
52
+ const tooltipFormatterJs = `function(params) {
53
+ var d = params.value || params.data;
54
+ var result = '';
55
+ ${labelIdx >= 0 ? "var label = d[" + labelIdx + "]; if (label) result += '<strong>' + label + '</strong><br/>';" : ''}
56
+ result += '${xField}' + ': ' + d[0] + '<br/>';
57
+ result += '${yField}' + ': ' + d[1] + '<br/>';
58
+ result += '${sizeField}' + ': ' + d[2];
59
+ return result;
60
+ }`;
45
61
  return {
46
62
  backgroundColor: 'transparent',
47
63
  animation: false,
@@ -51,6 +67,7 @@ export function buildBubbleOptions(spec) {
51
67
  backgroundColor: colors.paper,
52
68
  borderColor: colors.border,
53
69
  textStyle: { color: colors.text },
70
+ formatter: { _js_: tooltipFormatterJs },
54
71
  },
55
72
  grid: {
56
73
  left: '6%',
@@ -89,7 +106,7 @@ export function buildBubbleOptions(spec) {
89
106
  {
90
107
  type: 'scatter',
91
108
  data: bubbleData,
92
- symbolSize: '__SYMBOL_SIZE_FUNC__',
109
+ symbolSize: { _js_: 'function(val) { return val[3] || 20; }' },
93
110
  itemStyle: { color: palette[0], opacity: 0.55 },
94
111
  },
95
112
  ],
@@ -103,9 +120,7 @@ function generateBubble(spec) {
103
120
  const title = spec.title ?? '';
104
121
  const theme = (spec.theme ?? 'light');
105
122
  const colors = getThemeColors(theme);
106
- // Convert to JSON and inject the symbolSize function
107
- let optionJson = JSON.stringify(option, null, 2);
108
- optionJson = optionJson.replace('"__SYMBOL_SIZE_FUNC__"', 'function(val) { return val[3] || 20; }');
123
+ const optionJson = serializeOption(option);
109
124
  return `<!DOCTYPE html>
110
125
  <html lang="en">
111
126
  <head>
@@ -119,7 +134,7 @@ function generateBubble(spec) {
119
134
  background-color: ${colors.background}; font-family: ${FONT_STACK};
120
135
  }
121
136
  .container { padding: 20px; width: 100%; height: 100%; display: flex; flex-direction: column; }
122
- .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; }
123
138
  h2 { font-family: ${FONT_STACK}; color: ${colors.text}; font-size: 18px; font-weight: 900; margin: 0 0 4px 0; }
124
139
  #chart { width: 100%; flex: 1; min-height: 300px; }
125
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 } 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;
@@ -42,6 +42,121 @@ function getNiceStep(dataRange) {
42
42
  return magnitude / 2;
43
43
  return magnitude;
44
44
  }
45
+ // Font sizes for dumbbell dot labels
46
+ const DUMBBELL_LABEL_FONT = FONT_SIZE_TINY - 2; // 8px - normal
47
+ const DUMBBELL_LABEL_FONT_SMALL = FONT_SIZE_TINY - 3; // 7px - for 5+ char labels
48
+ const COMPACT_LABEL_CHAR_THRESHOLD = 5;
49
+ /**
50
+ * Compute the compact dumbbell label string for a given value.
51
+ * Mirrors the JS formatter logic so we can measure character count at build time.
52
+ */
53
+ function compactFormatLabel(value, fmt, currencySymbol = '$', locale = 'en-US') {
54
+ if (fmt === 'auto' || fmt === 'currency_auto') {
55
+ const abs = Math.abs(value);
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);
61
+ let r;
62
+ if (abs >= 1e9) {
63
+ const s = abs / 1e9;
64
+ r = p + (s >= 10 ? Math.round(s).toString() : lf(s, 1)) + 'b';
65
+ }
66
+ else if (abs >= 1e6) {
67
+ const s = abs / 1e6;
68
+ r = p + (s >= 10 ? Math.round(s).toString() : lf(s, 1)) + 'm';
69
+ }
70
+ else if (abs >= 1e3) {
71
+ const s = abs / 1e3;
72
+ r = p + (s >= 10 ? Math.round(s).toString() : lf(s, 1)) + 'k';
73
+ }
74
+ else {
75
+ r = abs === Math.floor(abs) ? p + abs.toString() : p + lf(abs, 1);
76
+ }
77
+ return value < 0 ? '(' + r + ')' : r;
78
+ }
79
+ return formatNumber(value, fmt);
80
+ }
81
+ /**
82
+ * Get the appropriate font size for a dumbbell dot label.
83
+ */
84
+ function dumbbellLabelFontSize(value, fmt, currencySymbol = '$', locale = 'en-US') {
85
+ const label = compactFormatLabel(value, fmt, currencySymbol, locale);
86
+ return label.length >= COMPACT_LABEL_CHAR_THRESHOLD
87
+ ? DUMBBELL_LABEL_FONT_SMALL
88
+ : DUMBBELL_LABEL_FONT;
89
+ }
90
+ /**
91
+ * Build a label formatter function for dumbbell dot labels.
92
+ * Dumbbell stores values as [value, categoryIndex], so we extract value from params.value[0].
93
+ * Uses compact formatting (~4-5 chars max) to fit inside small dot symbols.
94
+ */
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); };`;
103
+ // Compact: max 1 decimal for scaled values to fit in dot labels
104
+ return `function(params) {
105
+ var v = params.value[0];
106
+ if (v == null) return '';
107
+ var abs = Math.abs(v);
108
+ var neg = v < 0;
109
+ var p = ${isCurrency} ? ${symbolStr} : '';
110
+ ${lfHelper}
111
+ var r;
112
+ if (abs >= 1e9) {
113
+ var s = abs / 1e9;
114
+ r = p + (s >= 10 ? Math.round(s) : lf(s,1)) + 'b';
115
+ } else if (abs >= 1e6) {
116
+ var s = abs / 1e6;
117
+ r = p + (s >= 10 ? Math.round(s) : lf(s,1)) + 'm';
118
+ } else if (abs >= 1e3) {
119
+ var s = abs / 1e3;
120
+ r = p + (s >= 10 ? Math.round(s) : lf(s,1)) + 'k';
121
+ } else {
122
+ r = abs === Math.floor(abs) ? p + abs : p + lf(abs,1);
123
+ }
124
+ return neg ? '(' + r + ')' : r;
125
+ }`;
126
+ }
127
+ const expr = formatValue(fmt, currencySymbol, locale);
128
+ return `function(params) { var value = params.value[0]; if (value == null) return ''; return ${expr}; }`;
129
+ }
130
+ /**
131
+ * Build a tooltip value formatting expression for dumbbell charts.
132
+ * Tooltip can be more verbose than dot labels, so uses full smart formatting.
133
+ */
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); };`;
142
+ return `(function(v) {
143
+ var abs = Math.abs(v);
144
+ var neg = v < 0;
145
+ var p = ${isCurrency} ? ${symbolStr} : '';
146
+ ${lfHelper}
147
+ var r;
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); }
153
+ return neg ? '(' + r + ')' : r;
154
+ })(val)`;
155
+ }
156
+ const expr = formatValue(fmt, currencySymbol, locale);
157
+ // formatValue uses 'value' as the variable name, replace with 'val' for tooltip context
158
+ return expr.replace(/\bvalue\b/g, 'val');
159
+ }
45
160
  /**
46
161
  * Build ECharts options for a dumbbell plot with directional coloring
47
162
  */
@@ -78,8 +193,8 @@ export function buildDumbbellOptions(spec) {
78
193
  // Infer value format
79
194
  const sampleValue = startValues[0] ?? 0;
80
195
  const valueFormat = spec.format ?? inferFormat(startField, sampleValue);
81
- const isCurrency = valueFormat?.includes('usd');
82
- const axisFormatter = getAxisFormatterJs(valueFormat);
196
+ const currency = resolveCurrency(spec.currency);
197
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale);
83
198
  // Calculate axis limits
84
199
  const allValues = [...startValues, ...endValues];
85
200
  let xMin = spec.xMin;
@@ -99,24 +214,8 @@ export function buildDumbbellOptions(spec) {
99
214
  xMax = niceCeil(rawMax, step);
100
215
  }
101
216
  }
102
- // Value formatter for labels inside dots
103
- const valueFormatterJs = `function(params) {
104
- var v = params.value[0];
105
- var abs = Math.abs(v);
106
- var isCurrency = ${isCurrency ? 'true' : 'false'};
107
- var prefix = isCurrency ? '$' : '';
108
- if (abs >= 1000000) {
109
- var val = v / 1000000;
110
- var formatted = isCurrency ? Math.round(val) : (Math.abs(val) >= 10 ? Math.round(val) : val.toFixed(1));
111
- return prefix + formatted + 'm';
112
- }
113
- if (abs >= 1000) {
114
- var val = v / 1000;
115
- var formatted = isCurrency ? Math.round(val) : (Math.abs(val) >= 10 ? Math.round(val) : val.toFixed(1));
116
- return prefix + formatted + 'k';
117
- }
118
- return isCurrency ? prefix + Math.round(v) : v;
119
- }`;
217
+ // Value formatter for labels inside dots (uses formatValue for all format types)
218
+ const valueFormatterJs = buildDumbbellLabelFormatter(valueFormat, currency.symbol, currency.locale);
120
219
  // Build line data for custom series
121
220
  const lineData = startValues.map((s, i) => {
122
221
  const e = endValues[i];
@@ -227,7 +326,7 @@ export function buildDumbbellOptions(spec) {
227
326
  label: {
228
327
  show: showValues,
229
328
  position: 'inside',
230
- fontSize: FONT_SIZE_TINY - 2,
329
+ fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale),
231
330
  fontWeight: 'bold',
232
331
  color: rowColors[i],
233
332
  formatter: { _js_: valueFormatterJs },
@@ -250,7 +349,7 @@ export function buildDumbbellOptions(spec) {
250
349
  label: {
251
350
  show: showValues,
252
351
  position: 'inside',
253
- fontSize: FONT_SIZE_TINY - 2,
352
+ fontSize: dumbbellLabelFontSize(v, valueFormat, currency.symbol, currency.locale),
254
353
  fontWeight: 'bold',
255
354
  color: '#ffffff',
256
355
  formatter: { _js_: valueFormatterJs },
@@ -263,12 +362,14 @@ export function buildDumbbellOptions(spec) {
263
362
  symbolSize: DUMBBELL_SYMBOL_SIZE,
264
363
  z: 10,
265
364
  });
266
- // Tooltip formatter
365
+ // Tooltip formatter with value formatting
366
+ const tooltipValueExpr = buildDumbbellTooltipExpr(valueFormat, currency.symbol, currency.locale);
267
367
  const tooltipFormatterJs = `function(params) {
268
368
  if (params.seriesName === 'range') return '';
269
369
  var cat = ${JSON.stringify(categories)}[params.data.value ? params.data.value[1] : params.data[1]];
270
370
  var val = params.data.value ? params.data.value[0] : params.data[0];
271
- return '<strong>' + cat + '</strong><br/>' + params.seriesName + ': ' + val;
371
+ var formatted = ${tooltipValueExpr};
372
+ return '<strong>' + cat + '</strong><br/>' + params.seriesName + ': ' + formatted;
272
373
  }`;
273
374
  return {
274
375
  backgroundColor: 'transparent',
@@ -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 } 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
  */
@@ -13,6 +13,7 @@ export function buildScatterOptions(spec) {
13
13
  const xField = spec.x ?? 'x';
14
14
  const yField = spec.y ?? 'y';
15
15
  const seriesField = spec.series;
16
+ const labelField = spec.label;
16
17
  const theme = (spec.theme ?? 'light');
17
18
  const colors = getThemeColors(theme);
18
19
  const palette = getPaletteWithCustom(theme, spec.customTheme);
@@ -21,8 +22,9 @@ export function buildScatterOptions(spec) {
21
22
  const ySample = data.length > 0 && data[0] ? data[0][yField] ?? 0 : 0;
22
23
  const xFormat = spec.xFormat ?? inferFormat(xField, xSample);
23
24
  const yFormat = spec.yFormat ?? inferFormat(yField, ySample);
24
- const xAxisFormatter = getAxisFormatterJs(xFormat);
25
- 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);
26
28
  // Build series - either single series or grouped by seriesField
27
29
  let seriesList;
28
30
  let legendData = [];
@@ -34,7 +36,10 @@ export function buildScatterOptions(spec) {
34
36
  if (!groups.has(groupName)) {
35
37
  groups.set(groupName, []);
36
38
  }
37
- groups.get(groupName).push([d[xField] ?? 0, d[yField] ?? 0]);
39
+ const point = [d[xField] ?? 0, d[yField] ?? 0];
40
+ if (labelField)
41
+ point.push(String(d[labelField] ?? ''));
42
+ groups.get(groupName).push(point);
38
43
  }
39
44
  // Create a series for each group
40
45
  legendData = [...groups.keys()];
@@ -47,8 +52,13 @@ export function buildScatterOptions(spec) {
47
52
  }));
48
53
  }
49
54
  else {
50
- // Single series (original behavior)
51
- const scatterData = data.map((d) => [d[xField] ?? 0, d[yField] ?? 0]);
55
+ // Single series
56
+ const scatterData = data.map((d) => {
57
+ const point = [d[xField] ?? 0, d[yField] ?? 0];
58
+ if (labelField)
59
+ point.push(String(d[labelField] ?? ''));
60
+ return point;
61
+ });
52
62
  seriesList = [
53
63
  {
54
64
  type: 'scatter',
@@ -58,6 +68,17 @@ export function buildScatterOptions(spec) {
58
68
  },
59
69
  ];
60
70
  }
71
+ // Build tooltip formatter with label support and value formatting
72
+ const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale);
73
+ const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale);
74
+ const tooltipFormatterJs = `function(params) {
75
+ var d = params.value || params.data;
76
+ var result = '';
77
+ ${labelField ? "var label = d[2]; if (label) result += '<strong>' + label + '</strong><br/>';" : ''}
78
+ var value = d[0]; result += ${JSON.stringify(xField)} + ': ' + ${xFormatExpr} + '<br/>';
79
+ var value = d[1]; result += ${JSON.stringify(yField)} + ': ' + ${yFormatExpr};
80
+ return result;
81
+ }`;
61
82
  const option = {
62
83
  backgroundColor: 'transparent',
63
84
  animation: false,
@@ -67,6 +88,7 @@ export function buildScatterOptions(spec) {
67
88
  backgroundColor: colors.paper,
68
89
  borderColor: colors.border,
69
90
  textStyle: { color: colors.text },
91
+ formatter: { _js_: tooltipFormatterJs },
70
92
  },
71
93
  grid: {
72
94
  left: '2%',
@@ -93,7 +115,7 @@ export function buildScatterOptions(spec) {
93
115
  type: 'value',
94
116
  name: yField,
95
117
  nameLocation: 'middle',
96
- nameGap: 32,
118
+ nameGap: 40,
97
119
  nameTextStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
98
120
  axisLine: { show: false },
99
121
  axisTick: { show: false },