mviz 1.5.4 → 1.6.1

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
@@ -3,11 +3,11 @@
3
3
  */
4
4
  import type { ChartSpec } from '../types.js';
5
5
  /**
6
- * Build ECharts options for a bubble chart
6
+ * Build ECharts options for a bubble chart.
7
7
  */
8
8
  export declare function buildBubbleOptions(spec: ChartSpec): Record<string, unknown>;
9
9
  /**
10
- * Generate a bubble chart with JS function for symbol size
10
+ * Generate a bubble chart.
11
11
  */
12
12
  declare function generateBubble(spec: ChartSpec): string;
13
13
  export { generateBubble };
@@ -1,158 +1,200 @@
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';
5
- import { serializeOption } from '../core/serializer.js';
4
+ import { FONT_SIZE_TINY, DEFAULT_CHART_HEIGHT, getThemeColors, getPaletteWithCustom, } from '../core/themes.js';
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';
8
+ import { buildPointLabelConfig, groupDataBySeries, buildSeriesLegend } from '../core/chart-helpers.js';
9
+ /** Min/max bubble pixel sizes */
10
+ const BUBBLE_SIZE_MIN = 10;
11
+ const BUBBLE_SIZE_MAX = 50;
12
+ /** Bubble opacity */
13
+ const BUBBLE_OPACITY = 0.55;
14
+ /** Label sits at index 4 in bubble point arrays: [x, y, rawSize, scaledSize, label?] */
15
+ const BUBBLE_LABEL_INDEX = 4;
7
16
  /**
8
- * Build ECharts options for a bubble chart
17
+ * Scale a raw size value to a pixel diameter.
18
+ */
19
+ function scaleBubbleSize(raw, minSize, sizeRange) {
20
+ return BUBBLE_SIZE_MIN + ((raw - minSize) / sizeRange) * (BUBBLE_SIZE_MAX - BUBBLE_SIZE_MIN);
21
+ }
22
+ /**
23
+ * Compute global min/max/range for size field across all data.
24
+ */
25
+ function computeSizeRange(data, sizeField) {
26
+ const sizes = data.map((d) => (typeof d[sizeField] === 'number' ? d[sizeField] : 1));
27
+ const min = sizes.length > 0 ? Math.min(...sizes) : 1;
28
+ const max = sizes.length > 0 ? Math.max(...sizes) : 1;
29
+ return { min, range: max - min || 1 };
30
+ }
31
+ /**
32
+ * Detect whether a field contains category (string) data and extract unique values.
33
+ */
34
+ function detectAxisCategories(data, field) {
35
+ const first = data.length > 0 ? data[0][field] : null;
36
+ const isCategory = typeof first === 'string';
37
+ const categories = isCategory
38
+ ? [...new Set(data.map((d) => String(d[field] ?? '')))]
39
+ : [];
40
+ return { isCategory, categories };
41
+ }
42
+ /**
43
+ * Build a single bubble data point array: [x, y, rawSize, scaledSize, label?]
44
+ */
45
+ function buildBubblePoint(d, ctx) {
46
+ const s = typeof d[ctx.sizeField] === 'number' ? d[ctx.sizeField] : 1;
47
+ const scaledSize = scaleBubbleSize(s, ctx.minSize, ctx.sizeRange);
48
+ const xVal = ctx.xIsCategory ? ctx.xCategories.indexOf(String(d[ctx.xField] ?? '')) : (d[ctx.xField] ?? 0);
49
+ const yVal = ctx.yIsCategory ? ctx.yCategories.indexOf(String(d[ctx.yField] ?? '')) : (d[ctx.yField] ?? 0);
50
+ const point = [xVal, yVal, s, scaledSize];
51
+ if (ctx.labelField)
52
+ point.push(String(d[ctx.labelField] ?? ''));
53
+ return point;
54
+ }
55
+ /**
56
+ * Build series list and legend data for bubble chart.
57
+ */
58
+ function buildBubbleSeries(data, ctx, seriesField, showLabels, palette, colors) {
59
+ const labelConfig = showLabels && ctx.labelField
60
+ ? buildPointLabelConfig(BUBBLE_LABEL_INDEX, colors)
61
+ : undefined;
62
+ const pointBuilder = (d) => buildBubblePoint(d, ctx);
63
+ if (seriesField) {
64
+ const groups = groupDataBySeries(data, seriesField, pointBuilder);
65
+ const legendData = [...groups.keys()];
66
+ const seriesList = legendData.map((name, idx) => {
67
+ const obj = {
68
+ type: 'scatter',
69
+ name,
70
+ data: groups.get(name),
71
+ symbolSize: { _js_: 'function(val) { return val[3] || 20; }' },
72
+ itemStyle: { color: palette[idx % palette.length], opacity: BUBBLE_OPACITY },
73
+ };
74
+ if (labelConfig)
75
+ obj.label = labelConfig;
76
+ return obj;
77
+ });
78
+ return { seriesList, legendData };
79
+ }
80
+ const bubbleData = data.map(pointBuilder);
81
+ const obj = {
82
+ type: 'scatter',
83
+ data: bubbleData,
84
+ symbolSize: { _js_: 'function(val) { return val[3] || 20; }' },
85
+ itemStyle: { color: palette[0], opacity: BUBBLE_OPACITY },
86
+ };
87
+ if (labelConfig)
88
+ obj.label = labelConfig;
89
+ return { seriesList: [obj], legendData: [] };
90
+ }
91
+ /**
92
+ * Build tooltip config for bubble chart.
93
+ */
94
+ function buildBubbleTooltip(ctx, seriesField, xFormat, yFormat, currency, colors) {
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);
98
+ const seriesNameLine = seriesField
99
+ ? "if (params.seriesName) result += '<strong>' + params.seriesName + '</strong><br/>';"
100
+ : '';
101
+ const formatterJs = `function(params) {
102
+ var d = params.value || params.data;
103
+ var result = '';
104
+ ${seriesNameLine}
105
+ ${labelIdx >= 0 ? "var label = d[" + labelIdx + "]; if (label) result += '<strong>' + label + '</strong><br/>';" : ''}
106
+ var value = d[0]; result += ${JSON.stringify(ctx.xField)} + ': ' + ${xFormatExpr} + '<br/>';
107
+ var value = d[1]; result += ${JSON.stringify(ctx.yField)} + ': ' + ${yFormatExpr} + '<br/>';
108
+ result += ${JSON.stringify(ctx.sizeField)} + ': ' + d[2];
109
+ return result;
110
+ }`;
111
+ return {
112
+ trigger: 'item',
113
+ backgroundColor: colors.paper,
114
+ borderColor: colors.border,
115
+ textStyle: { color: colors.text },
116
+ formatter: { _js_: formatterJs },
117
+ };
118
+ }
119
+ /**
120
+ * Build a single axis config for bubble chart.
121
+ */
122
+ function buildBubbleAxis(field, isCategory, categories, formatter, nameGap, colors) {
123
+ return {
124
+ type: isCategory ? 'category' : 'value',
125
+ data: isCategory ? categories : undefined,
126
+ name: field,
127
+ nameLocation: 'middle',
128
+ nameGap,
129
+ nameTextStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
130
+ scale: !isCategory,
131
+ axisLine: { show: false },
132
+ axisTick: { show: false },
133
+ axisLabel: {
134
+ color: colors.textSecondary,
135
+ ...(formatter && { formatter }),
136
+ },
137
+ splitLine: { lineStyle: { color: colors.border, type: [2, 3], opacity: 0.6 } },
138
+ };
139
+ }
140
+ /**
141
+ * Build ECharts options for a bubble chart.
9
142
  */
10
143
  export function buildBubbleOptions(spec) {
11
144
  const data = (spec.data ?? []);
12
145
  const xField = spec.x ?? 'x';
13
146
  const yField = spec.y ?? 'y';
14
147
  const sizeField = spec.size ?? 'size';
148
+ const seriesField = spec.series;
15
149
  const labelField = spec.label;
150
+ const showLabels = spec.showLabels;
16
151
  const theme = (spec.theme ?? 'light');
17
152
  const colors = getThemeColors(theme);
18
153
  const palette = getPaletteWithCustom(theme, spec.customTheme);
19
- // Auto-detect axis types from data
20
- const firstX = data.length > 0 ? data[0][xField] : null;
21
- const firstY = data.length > 0 ? data[0][yField] : null;
22
- const xIsCategory = typeof firstX === 'string';
23
- const yIsCategory = typeof firstY === 'string';
24
- // Extract unique categories if needed
25
- let xCategories = [];
26
- let yCategories = [];
27
- if (xIsCategory) {
28
- xCategories = [...new Set(data.map((d) => String(d[xField] ?? '')))];
29
- }
30
- if (yIsCategory) {
31
- yCategories = [...new Set(data.map((d) => String(d[yField] ?? '')))];
32
- }
33
- // Find min/max for size scaling
34
- const sizes = data.map((d) => (typeof d[sizeField] === 'number' ? d[sizeField] : 1));
35
- const minSize = sizes.length > 0 ? Math.min(...sizes) : 1;
36
- const maxSize = sizes.length > 0 ? Math.max(...sizes) : 1;
37
- const sizeRange = maxSize - minSize || 1;
38
- // Build bubble data with scaled sizes
39
- // For category axes, convert string values to indices
40
- const bubbleData = data.map((d) => {
41
- const s = typeof d[sizeField] === 'number' ? d[sizeField] : 1;
42
- const scaledSize = 10 + ((s - minSize) / sizeRange) * 40;
43
- const xVal = xIsCategory ? xCategories.indexOf(String(d[xField] ?? '')) : (d[xField] ?? 0);
44
- const yVal = yIsCategory ? yCategories.indexOf(String(d[yField] ?? '')) : (d[yField] ?? 0);
45
- const point = [xVal, yVal, s, scaledSize];
46
- if (labelField)
47
- point.push(String(d[labelField] ?? ''));
48
- return point;
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
- }`;
61
- return {
154
+ // Infer axis formats
155
+ const xSample = data.length > 0 && data[0] ? data[0][xField] ?? 0 : 0;
156
+ const ySample = data.length > 0 && data[0] ? data[0][yField] ?? 0 : 0;
157
+ const xFormat = spec.xFormat ?? inferFormat(xField, xSample);
158
+ const yFormat = spec.yFormat ?? inferFormat(yField, ySample);
159
+ const currency = resolveCurrency(spec.currency);
160
+ // Build context for point construction
161
+ const xAxis = detectAxisCategories(data, xField);
162
+ const yAxis = detectAxisCategories(data, yField);
163
+ const { min: minSize, range: sizeRange } = computeSizeRange(data, sizeField);
164
+ const ctx = {
165
+ xField, yField, sizeField, labelField,
166
+ xIsCategory: xAxis.isCategory, yIsCategory: yAxis.isCategory,
167
+ xCategories: xAxis.categories, yCategories: yAxis.categories,
168
+ minSize, sizeRange,
169
+ };
170
+ // Build series and legend
171
+ const { seriesList, legendData } = buildBubbleSeries(data, ctx, seriesField, showLabels, palette, colors);
172
+ const hasLegend = seriesField && legendData.length > 1;
173
+ const option = {
62
174
  backgroundColor: 'transparent',
63
175
  animation: false,
64
176
  color: palette,
65
- tooltip: {
66
- trigger: 'item',
67
- backgroundColor: colors.paper,
68
- borderColor: colors.border,
69
- textStyle: { color: colors.text },
70
- formatter: { _js_: tooltipFormatterJs },
71
- },
177
+ tooltip: buildBubbleTooltip(ctx, seriesField, xFormat, yFormat, currency, colors),
72
178
  grid: {
73
- left: '6%',
74
- right: '8%',
75
- top: '12%',
76
- bottom: '14%',
77
- containLabel: true,
78
- },
79
- xAxis: {
80
- type: xIsCategory ? 'category' : 'value',
81
- data: xIsCategory ? xCategories : undefined,
82
- name: xField,
83
- nameLocation: 'middle',
84
- nameGap: 24,
85
- nameTextStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
86
- scale: !xIsCategory,
87
- axisLine: { show: false },
88
- axisTick: { show: false },
89
- axisLabel: { color: colors.textSecondary },
90
- splitLine: { lineStyle: { color: colors.border, type: [2, 3], opacity: 0.6 } },
91
- },
92
- yAxis: {
93
- type: yIsCategory ? 'category' : 'value',
94
- data: yIsCategory ? yCategories : undefined,
95
- name: yField,
96
- nameLocation: 'middle',
97
- nameGap: 32,
98
- nameTextStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
99
- scale: !yIsCategory,
100
- axisLine: { show: false },
101
- axisTick: { show: false },
102
- axisLabel: { color: colors.textSecondary },
103
- splitLine: { lineStyle: { color: colors.border, type: [2, 3], opacity: 0.6 } },
179
+ left: '6%', right: '8%',
180
+ top: hasLegend ? '14%' : '12%',
181
+ bottom: '14%', containLabel: true,
104
182
  },
105
- series: [
106
- {
107
- type: 'scatter',
108
- data: bubbleData,
109
- symbolSize: { _js_: 'function(val) { return val[3] || 20; }' },
110
- itemStyle: { color: palette[0], opacity: 0.55 },
111
- },
112
- ],
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
+ series: seriesList,
113
186
  };
187
+ if (hasLegend) {
188
+ option.legend = buildSeriesLegend(legendData, colors);
189
+ }
190
+ return option;
114
191
  }
115
192
  /**
116
- * Generate a bubble chart with JS function for symbol size
193
+ * Generate a bubble chart.
117
194
  */
118
195
  function generateBubble(spec) {
119
- const option = buildBubbleOptions(spec);
120
- const title = spec.title ?? '';
121
- const theme = (spec.theme ?? 'light');
122
- const colors = getThemeColors(theme);
123
- const optionJson = serializeOption(option);
124
- return `<!DOCTYPE html>
125
- <html lang="en">
126
- <head>
127
- <meta charset="utf-8">
128
- <title>${title}</title>
129
- <script src="${ECHARTS_CDN}"></script>
130
- <style>
131
- * { box-sizing: border-box; }
132
- html, body {
133
- margin: 0; padding: 0; width: 100%; height: 100%;
134
- background-color: ${colors.background}; font-family: ${FONT_STACK};
135
- }
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; }
138
- h2 { font-family: ${FONT_STACK}; color: ${colors.text}; font-size: 18px; font-weight: 900; margin: 0 0 4px 0; }
139
- #chart { width: 100%; flex: 1; min-height: 300px; }
140
- </style>
141
- </head>
142
- <body>
143
- <div class="container">
144
- <div class="red-line"></div>
145
- <h2>${title}</h2>
146
- <div id="chart"></div>
147
- </div>
148
- <script>
149
- var chart = echarts.init(document.getElementById('chart'));
150
- var option = ${optionJson};
151
- chart.setOption(option);
152
- window.addEventListener('resize', function() { chart.resize(); });
153
- </script>
154
- </body>
155
- </html>`;
196
+ const height = typeof spec.height === 'number' ? spec.height : DEFAULT_CHART_HEIGHT;
197
+ return wrapHtml('chart', buildBubbleOptions(spec), '', '100%', height);
156
198
  }
157
199
  // Register the chart
158
200
  registerChart('bubble')(generateBubble);
@@ -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]];