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.
@@ -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,21 @@
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
+ import { buildPointLabelConfig, groupDataBySeries, buildSeriesLegend } from '../core/chart-helpers.js';
9
+ /** Scatter point opacity */
10
+ const SCATTER_OPACITY = 0.75;
11
+ /** Label sits at index 2 in scatter point arrays: [x, y, label?] */
12
+ const SCATTER_LABEL_INDEX = 2;
13
+ /**
14
+ * Build a scatter data point: [x, y, label?]
15
+ */
16
+ function buildScatterPoint(d, xField, yField, labelField) {
17
+ const point = [d[xField] ?? 0, d[yField] ?? 0];
18
+ if (labelField)
19
+ point.push(String(d[labelField] ?? ''));
20
+ return point;
21
+ }
8
22
  /**
9
23
  * Build ECharts options for a scatter plot
10
24
  */
@@ -14,6 +28,7 @@ export function buildScatterOptions(spec) {
14
28
  const yField = spec.y ?? 'y';
15
29
  const seriesField = spec.series;
16
30
  const labelField = spec.label;
31
+ const showLabels = spec.showLabels;
17
32
  const theme = (spec.theme ?? 'light');
18
33
  const colors = getThemeColors(theme);
19
34
  const palette = getPaletteWithCustom(theme, spec.customTheme);
@@ -22,54 +37,48 @@ export function buildScatterOptions(spec) {
22
37
  const ySample = data.length > 0 && data[0] ? data[0][yField] ?? 0 : 0;
23
38
  const xFormat = spec.xFormat ?? inferFormat(xField, xSample);
24
39
  const yFormat = spec.yFormat ?? inferFormat(yField, ySample);
25
- const xAxisFormatter = getAxisFormatterJs(xFormat);
26
- const yAxisFormatter = getAxisFormatterJs(yFormat);
40
+ const currency = resolveCurrency(spec.currency);
41
+ const xAxisFormatter = getAxisFormatterJs(xFormat, currency.symbol, currency.locale);
42
+ const yAxisFormatter = getAxisFormatterJs(yFormat, currency.symbol, currency.locale);
43
+ // Label config for persistent point labels
44
+ const labelConfig = showLabels && labelField
45
+ ? buildPointLabelConfig(SCATTER_LABEL_INDEX, colors)
46
+ : undefined;
27
47
  // Build series - either single series or grouped by seriesField
28
48
  let seriesList;
29
49
  let legendData = [];
50
+ const pointBuilder = (d) => buildScatterPoint(d, xField, yField, labelField);
30
51
  if (seriesField) {
31
- // Group data by the series field
32
- const groups = new Map();
33
- for (const d of data) {
34
- const groupName = String(d[seriesField] ?? 'Unknown');
35
- if (!groups.has(groupName)) {
36
- groups.set(groupName, []);
37
- }
38
- const point = [d[xField] ?? 0, d[yField] ?? 0];
39
- if (labelField)
40
- point.push(String(d[labelField] ?? ''));
41
- groups.get(groupName).push(point);
42
- }
43
- // Create a series for each group
52
+ const groups = groupDataBySeries(data, seriesField, pointBuilder);
44
53
  legendData = [...groups.keys()];
45
- seriesList = legendData.map((name, idx) => ({
46
- type: 'scatter',
47
- name,
48
- data: groups.get(name),
49
- symbolSize: SCATTER_SYMBOL_SIZE,
50
- itemStyle: { color: palette[idx % palette.length], opacity: 0.75 },
51
- }));
52
- }
53
- else {
54
- // Single series
55
- const scatterData = data.map((d) => {
56
- const point = [d[xField] ?? 0, d[yField] ?? 0];
57
- if (labelField)
58
- point.push(String(d[labelField] ?? ''));
59
- return point;
60
- });
61
- seriesList = [
62
- {
54
+ seriesList = legendData.map((name, idx) => {
55
+ const seriesObj = {
63
56
  type: 'scatter',
64
- data: scatterData,
57
+ name,
58
+ data: groups.get(name),
65
59
  symbolSize: SCATTER_SYMBOL_SIZE,
66
- itemStyle: { color: palette[0], opacity: 0.75 },
67
- },
68
- ];
60
+ itemStyle: { color: palette[idx % palette.length], opacity: SCATTER_OPACITY },
61
+ };
62
+ if (labelConfig)
63
+ seriesObj.label = labelConfig;
64
+ return seriesObj;
65
+ });
66
+ }
67
+ else {
68
+ const scatterData = data.map(pointBuilder);
69
+ const seriesObj = {
70
+ type: 'scatter',
71
+ data: scatterData,
72
+ symbolSize: SCATTER_SYMBOL_SIZE,
73
+ itemStyle: { color: palette[0], opacity: SCATTER_OPACITY },
74
+ };
75
+ if (labelConfig)
76
+ seriesObj.label = labelConfig;
77
+ seriesList = [seriesObj];
69
78
  }
70
79
  // Build tooltip formatter with label support and value formatting
71
- const xFormatExpr = formatValue(xFormat);
72
- const yFormatExpr = formatValue(yFormat);
80
+ const xFormatExpr = formatValue(xFormat, currency.symbol, currency.locale);
81
+ const yFormatExpr = formatValue(yFormat, currency.symbol, currency.locale);
73
82
  const tooltipFormatterJs = `function(params) {
74
83
  var d = params.value || params.data;
75
84
  var result = '';
@@ -128,12 +137,7 @@ export function buildScatterOptions(spec) {
128
137
  };
129
138
  // Add legend for multi-series
130
139
  if (seriesField && legendData.length > 1) {
131
- option.legend = {
132
- show: true,
133
- data: legendData,
134
- top: 0,
135
- textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
136
- };
140
+ option.legend = buildSeriesLegend(legendData, colors);
137
141
  }
138
142
  return option;
139
143
  }
@@ -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>
@@ -2,7 +2,7 @@
2
2
  * Delta/change indicator 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
  /**
8
8
  * Generate a delta/change indicator
@@ -21,6 +21,7 @@ function generateDelta(spec) {
21
21
  const title = showTitleHeader ? specTitle : '';
22
22
  // Auto-infer format from label if not specified
23
23
  const fmt = spec.format ?? inferFormat(label, value);
24
+ const currency = resolveCurrency(spec.currency);
24
25
  const colors = getThemeColors(theme);
25
26
  // Determine direction and color
26
27
  const isPositive = value > neutralIs;
@@ -40,7 +41,7 @@ function generateDelta(spec) {
40
41
  color = COLORS.ERROR_RED;
41
42
  arrow = isPositive ? '↑' : '↓';
42
43
  }
43
- const displayValue = formatNumber(value, fmt, true);
44
+ const displayValue = formatNumber(value, fmt, true, currency);
44
45
  const titleUpper = title.toUpperCase();
45
46
  const titleHtml = title ? `<h2>${titleUpper}</h2>` : '';
46
47
  return `<!DOCTYPE html>
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { ComponentSpec } from '../types.js';
5
5
  import { getThemeColors } from '../core/themes.js';
6
- import { type FormatType } from '../core/formatting.js';
6
+ import { type FormatType, type CurrencyConfig } from '../core/formatting.js';
7
7
  /**
8
8
  * Column definition for table configuration
9
9
  */
@@ -81,7 +81,7 @@ export declare function formatCell(value: unknown, colDef: ColumnDef, themeColor
81
81
  }>, dumbbellRanges: Map<string, {
82
82
  min: number;
83
83
  max: number;
84
- }>, defaultHeatmapColors: string[], customPalette?: readonly string[]): CellResult;
84
+ }>, defaultHeatmapColors: string[], customPalette?: readonly string[], currency?: CurrencyConfig): CellResult;
85
85
  /**
86
86
  * Generate a styled data table
87
87
  */
@@ -2,7 +2,7 @@
2
2
  * Data table component
3
3
  */
4
4
  import { COLORS, PALETTE, FONT_STACK, SPARKLINE_WIDTH, SPARKLINE_HEIGHT, DUMBBELL_DOT_RADIUS, getThemeColors, getHeatmapColors, getSparklineColors, } from '../core/themes.js';
5
- import { formatNumber, inferFormat } from '../core/formatting.js';
5
+ import { formatNumber, inferFormat, resolveCurrency } from '../core/formatting.js';
6
6
  import { calculateHeatmapColorWithContrast } from '../core/colors.js';
7
7
  import { registerComponent } from './registry.js';
8
8
  /**
@@ -334,7 +334,7 @@ function formatSparklineCell(rawValue, colDef, colId, themeColors, dumbbellRange
334
334
  /**
335
335
  * Format a cell value and return content with optional styling
336
336
  */
337
- export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRanges, defaultHeatmapColors, customPalette) {
337
+ export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRanges, defaultHeatmapColors, customPalette, currency) {
338
338
  const colType = colDef.type;
339
339
  const colId = colDef.id;
340
340
  const palette = customPalette ?? PALETTE;
@@ -350,7 +350,10 @@ export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRa
350
350
  if (colType === 'heatmap') {
351
351
  const range = heatmapRanges.get(colId);
352
352
  if (typeof rawValue === 'number' && range) {
353
- const heatmapColors = colDef.heatmapColors ?? defaultHeatmapColors;
353
+ const baseColors = colDef.heatmapColors ?? defaultHeatmapColors;
354
+ const heatmapColors = colDef.higherIsBetter === false
355
+ ? [...baseColors].reverse()
356
+ : baseColors;
354
357
  const colorResult = calculateHeatmapColorWithContrast(rawValue, range.min, range.max, heatmapColors);
355
358
  bgColor = colorResult.bgColor;
356
359
  textColor = colorResult.textColor;
@@ -367,7 +370,7 @@ export function formatCell(value, colDef, themeColors, heatmapRanges, dumbbellRa
367
370
  formatted = rawValue;
368
371
  }
369
372
  else {
370
- formatted = formatNumber(rawValue, fmt);
373
+ formatted = formatNumber(rawValue, fmt, false, currency);
371
374
  }
372
375
  // Apply bold/italic styling
373
376
  if (styles.bold) {
@@ -404,6 +407,7 @@ function parseTableConfig(spec) {
404
407
  heatmapRanges: computeHeatmapRanges(columns, data),
405
408
  dumbbellRanges: computeDumbbellRanges(columns, data),
406
409
  defaultHeatmapColors: getHeatmapColors(theme),
410
+ currency: resolveCurrency(spec.currency),
407
411
  };
408
412
  }
409
413
  /**
@@ -460,7 +464,7 @@ function buildDataRows(cfg, inlineStyles = false) {
460
464
  for (const col of cfg.columns) {
461
465
  const value = rowData[col.id];
462
466
  const align = col.align ?? 'left';
463
- const cellResult = formatCell(value, col, cfg.colors, cfg.heatmapRanges, cfg.dumbbellRanges, cfg.defaultHeatmapColors);
467
+ const cellResult = formatCell(value, col, cfg.colors, cfg.heatmapRanges, cfg.dumbbellRanges, cfg.defaultHeatmapColors, undefined, cfg.currency);
464
468
  if (inlineStyles) {
465
469
  const border = isLast ? `border-bottom: 1px solid ${cfg.colors.text};` : '';
466
470
  const cellBg = cellResult.bgColor ?? rowBg;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared helpers for scatter-family charts (scatter, bubble).
3
+ *
4
+ * Consolidates series-grouping, label config, and legend patterns
5
+ * that were duplicated across scatter.ts and bubble.ts.
6
+ */
7
+ import type { DataPoint, ThemeColors } from '../types.js';
8
+ /**
9
+ * Build ECharts label config for persistent point labels.
10
+ * @param labelIndex - Index of the label value in the point array
11
+ */
12
+ export declare function buildPointLabelConfig(labelIndex: number, colors: ThemeColors): Record<string, unknown>;
13
+ /**
14
+ * Group data points by a series field, applying a point builder to each.
15
+ */
16
+ export declare function groupDataBySeries<T>(data: DataPoint[], seriesField: string, buildPoint: (d: DataPoint) => T): Map<string, T[]>;
17
+ /**
18
+ * Build ECharts legend config for multi-series charts.
19
+ */
20
+ export declare function buildSeriesLegend(legendData: string[], colors: ThemeColors): Record<string, unknown>;
21
+ //# sourceMappingURL=chart-helpers.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared helpers for scatter-family charts (scatter, bubble).
3
+ *
4
+ * Consolidates series-grouping, label config, and legend patterns
5
+ * that were duplicated across scatter.ts and bubble.ts.
6
+ */
7
+ import { FONT_SIZE_TINY } from './themes.js';
8
+ /**
9
+ * Build ECharts label config for persistent point labels.
10
+ * @param labelIndex - Index of the label value in the point array
11
+ */
12
+ export function buildPointLabelConfig(labelIndex, colors) {
13
+ return {
14
+ show: true,
15
+ formatter: { _js_: `function(params) { return params.value[${labelIndex}] || ""; }` },
16
+ position: 'right',
17
+ fontSize: FONT_SIZE_TINY,
18
+ color: colors.textSecondary,
19
+ };
20
+ }
21
+ /**
22
+ * Group data points by a series field, applying a point builder to each.
23
+ */
24
+ export function groupDataBySeries(data, seriesField, buildPoint) {
25
+ const groups = new Map();
26
+ for (const d of data) {
27
+ const groupName = String(d[seriesField] ?? 'Unknown');
28
+ if (!groups.has(groupName)) {
29
+ groups.set(groupName, []);
30
+ }
31
+ groups.get(groupName).push(buildPoint(d));
32
+ }
33
+ return groups;
34
+ }
35
+ /**
36
+ * Build ECharts legend config for multi-series charts.
37
+ */
38
+ export function buildSeriesLegend(legendData, colors) {
39
+ return {
40
+ show: true,
41
+ data: legendData,
42
+ top: 0,
43
+ textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
44
+ };
45
+ }
46
+ //# sourceMappingURL=chart-helpers.js.map