eleventy-plugin-uncharted 0.1.2 → 0.2.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.
package/README.md CHANGED
@@ -48,6 +48,61 @@ If you set `injectCss: false`, you'll need to manually include the stylesheet in
48
48
  | `dot` | Categorical dot chart with Y-axis positioning | Yes |
49
49
  | `scatter` | XY scatter plot with continuous axes | Yes (X and Y) |
50
50
 
51
+ ## Value Formatting
52
+
53
+ Format displayed numbers with thousands separators, compact notation, or currency symbols.
54
+ Raw values are preserved for calculations; only display output is affected.
55
+
56
+ ### Options
57
+
58
+ | Option | Type | Description |
59
+ |--------|------|-------------|
60
+ | `thousands` | boolean | Add commas: `1000` → `1,000` |
61
+ | `compact` | boolean | Use suffixes: `1000` → `1K`, `1000000` → `1M` |
62
+ | `decimals` | number | Decimal places (default: 0, or 1 if compact) |
63
+ | `currency.symbol` | string | Currency symbol: `$`, `€`, etc. |
64
+ | `currency.position` | string | `prefix` (default) or `suffix` |
65
+
66
+ ### Examples
67
+
68
+ **Thousands separators:**
69
+
70
+ ```yaml
71
+ format:
72
+ thousands: true
73
+ # 1234567 → 1,234,567
74
+ ```
75
+
76
+ **Compact notation:**
77
+
78
+ ```yaml
79
+ format:
80
+ compact: true
81
+ # 1500 → 1.5K, 2000000 → 2M
82
+ ```
83
+
84
+ **Currency:**
85
+
86
+ ```yaml
87
+ format:
88
+ thousands: true
89
+ currency:
90
+ symbol: "$"
91
+ # 1234 → $1,234
92
+ ```
93
+
94
+ **Scatter charts** support separate X/Y formatting:
95
+
96
+ ```yaml
97
+ format:
98
+ x:
99
+ thousands: true
100
+ y:
101
+ compact: true
102
+ currency:
103
+ symbol: "$"
104
+ ```
105
+
51
106
  ## Usage
52
107
 
53
108
  ### Page Frontmatter
@@ -119,13 +174,24 @@ Sales,16,2
119
174
  Core,8,0
120
175
  ```
121
176
 
122
- For scatter plots, use `label`, `x`, `y`, and optionally `series`:
177
+ For scatter plots, columns are positional: point label, X value, Y value, and optionally series. Column names become axis labels by default:
123
178
 
124
179
  ```csv
125
- label,x,y,series
126
- Point A,10,45,alpha
127
- Point B,25,78,alpha
128
- Point C,15,32,beta
180
+ country,population,gdp,region
181
+ USA,330,21,Americas
182
+ China,1400,14,Asia
183
+ Germany,83,4,Europe
184
+ ```
185
+
186
+ This displays "population" as the X-axis title and "gdp" as the Y-axis title. Override with explicit titles:
187
+
188
+ ```yaml
189
+ charts:
190
+ my-scatter:
191
+ type: scatter
192
+ file: charts/data.csv
193
+ titleX: "Population (millions)"
194
+ titleY: "GDP (trillions)"
129
195
  ```
130
196
 
131
197
  ## Negative Values
@@ -159,9 +225,12 @@ The chart automatically calculates the range from the maximum positive stack to
159
225
  | `maxY` | number | Maximum Y value (scatter only) |
160
226
  | `minX` | number | Minimum X value (scatter only) |
161
227
  | `minY` | number | Minimum Y value (scatter only) |
228
+ | `titleX` | string | X-axis title (scatter only, defaults to column name) |
229
+ | `titleY` | string | Y-axis title (scatter only, defaults to column name) |
162
230
  | `legend` | array | Custom legend labels |
163
231
  | `center` | object | Donut center content (`value`, `label`) |
164
232
  | `animate` | boolean | Override global animation setting |
233
+ | `format` | object | Number formatting options (see Value Formatting) |
165
234
 
166
235
  ## Styling
167
236
 
package/css/uncharted.css CHANGED
@@ -14,7 +14,11 @@
14
14
  --chart-color-5: #009688; /* Teal */
15
15
  --chart-color-6: #9c27b0; /* Purple */
16
16
  --chart-color-7: #e91e63; /* Pink */
17
- --chart-color-8: #78909c; /* Gray */
17
+ --chart-color-8: #3f51b5; /* Indigo */
18
+ --chart-color-9: #f44336; /* Red */
19
+ --chart-color-10: #00bcd4; /* Cyan */
20
+ --chart-color-11: #cddc39; /* Lime */
21
+ --chart-color-12: #78909c; /* Gray */
18
22
 
19
23
  /* Backgrounds - neutral with opacity for light/dark adaptability */
20
24
  --chart-bg: rgba(128, 128, 128, 0.15);
@@ -40,6 +44,10 @@
40
44
  .chart-color-6 { --color: var(--chart-color-6); background-color: var(--chart-color-6); }
41
45
  .chart-color-7 { --color: var(--chart-color-7); background-color: var(--chart-color-7); }
42
46
  .chart-color-8 { --color: var(--chart-color-8); background-color: var(--chart-color-8); }
47
+ .chart-color-9 { --color: var(--chart-color-9); background-color: var(--chart-color-9); }
48
+ .chart-color-10 { --color: var(--chart-color-10); background-color: var(--chart-color-10); }
49
+ .chart-color-11 { --color: var(--chart-color-11); background-color: var(--chart-color-11); }
50
+ .chart-color-12 { --color: var(--chart-color-12); background-color: var(--chart-color-12); }
43
51
 
44
52
  /* ==========================================================================
45
53
  Base Chart Styles
@@ -71,7 +79,8 @@
71
79
  .chart-legend {
72
80
  display: flex;
73
81
  flex-wrap: wrap;
74
- gap: 1rem;
82
+ column-gap: 1rem;
83
+ row-gap: 0.375rem;
75
84
  list-style: none;
76
85
  padding: 0;
77
86
  margin: 0 0 1rem 0;
@@ -115,6 +124,7 @@
115
124
  }
116
125
 
117
126
  .chart-y-axis {
127
+ position: relative;
118
128
  display: flex;
119
129
  flex-direction: column;
120
130
  justify-content: space-between;
@@ -137,12 +147,24 @@
137
147
  transform: translateY(-50%);
138
148
  }
139
149
 
140
- .chart-y-axis .axis-label:last-child {
150
+ .chart-y-axis .axis-label:nth-child(3) {
141
151
  transform: translateY(50%);
142
152
  }
143
153
 
154
+ .chart-y-axis .axis-title {
155
+ position: absolute;
156
+ left: -0.5rem;
157
+ top: 50%;
158
+ transform: rotate(-90deg) translateX(-50%);
159
+ transform-origin: left center;
160
+ font-size: 0.7rem;
161
+ opacity: 0.6;
162
+ white-space: nowrap;
163
+ }
164
+
144
165
  .chart-x-axis {
145
166
  display: flex;
167
+ flex-wrap: wrap;
146
168
  justify-content: space-between;
147
169
  padding: 0.25rem 0;
148
170
  margin-top: 0.25rem;
@@ -159,25 +181,32 @@
159
181
  transform: translateX(-50%);
160
182
  }
161
183
 
162
- .chart-x-axis .axis-label:last-child {
184
+ .chart-x-axis .axis-label:nth-child(3) {
163
185
  transform: translateX(50%);
164
186
  }
165
187
 
188
+ .chart-x-axis .axis-title {
189
+ flex-basis: 100%;
190
+ text-align: center;
191
+ font-size: 0.7rem;
192
+ opacity: 0.6;
193
+ white-space: nowrap;
194
+ margin-top: 0.5rem;
195
+ }
196
+
166
197
  /* ==========================================================================
167
198
  Stacked Bar Chart (Horizontal)
168
199
  ========================================================================== */
169
200
 
170
201
  .chart-stacked-bar .chart-bars {
171
- display: flex;
172
- flex-direction: column;
173
- gap: var(--chart-gap);
202
+ display: grid;
203
+ grid-template-columns: auto 1fr auto;
204
+ gap: var(--chart-gap) 0.75rem;
205
+ align-items: center;
174
206
  }
175
207
 
176
208
  .chart-stacked-bar .bar-row {
177
- display: grid;
178
- grid-template-columns: minmax(6rem, auto) 1fr auto;
179
- align-items: center;
180
- gap: 0.75rem;
209
+ display: contents; /* Children participate in parent grid */
181
210
  }
182
211
 
183
212
  .chart-stacked-bar .bar-label {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
5
5
  "main": "eleventy.config.js",
6
6
  "type": "module",
package/src/csv.js CHANGED
@@ -1,6 +1,51 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ /**
5
+ * Parse a CSV line handling quoted fields
6
+ * @param {string} line - CSV line
7
+ * @returns {string[]} - Array of field values
8
+ */
9
+ function parseCSVLine(line) {
10
+ const fields = [];
11
+ let current = '';
12
+ let inQuotes = false;
13
+
14
+ for (let i = 0; i < line.length; i++) {
15
+ const char = line[i];
16
+ const nextChar = line[i + 1];
17
+
18
+ if (inQuotes) {
19
+ if (char === '"' && nextChar === '"') {
20
+ // Escaped quote
21
+ current += '"';
22
+ i++;
23
+ } else if (char === '"') {
24
+ // End of quoted field
25
+ inQuotes = false;
26
+ } else {
27
+ current += char;
28
+ }
29
+ } else {
30
+ if (char === '"') {
31
+ // Start of quoted field
32
+ inQuotes = true;
33
+ } else if (char === ',') {
34
+ // Field separator
35
+ fields.push(current.trim());
36
+ current = '';
37
+ } else {
38
+ current += char;
39
+ }
40
+ }
41
+ }
42
+
43
+ // Push last field
44
+ fields.push(current.trim());
45
+
46
+ return fields;
47
+ }
48
+
4
49
  /**
5
50
  * Parse CSV content into array of objects
6
51
  * @param {string} content - Raw CSV content
@@ -14,11 +59,11 @@ export function parseCSV(content) {
14
59
 
15
60
  if (lines.length < 2) return [];
16
61
 
17
- const headers = lines[0].split(',').map(h => h.trim());
62
+ const headers = parseCSVLine(lines[0]);
18
63
  const rows = [];
19
64
 
20
65
  for (let i = 1; i < lines.length; i++) {
21
- const values = lines[i].split(',').map(v => v.trim());
66
+ const values = parseCSVLine(lines[i]);
22
67
  const row = {};
23
68
 
24
69
  headers.forEach((header, index) => {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Format a number according to configuration
3
+ * @param {number} value - Raw numeric value
4
+ * @param {Object} config - { thousands, compact, decimals, currency: { symbol, position } }
5
+ * @returns {string} - Formatted string
6
+ */
7
+ export function formatNumber(value, config = {}) {
8
+ if (value == null || isNaN(value)) return '';
9
+
10
+ const { thousands, compact, decimals, currency } = config;
11
+ let num = value;
12
+ let suffix = '';
13
+
14
+ // Compact notation (K/M/B/T)
15
+ if (compact) {
16
+ const abs = Math.abs(num);
17
+ if (abs >= 1e12) { num /= 1e12; suffix = 'T'; }
18
+ else if (abs >= 1e9) { num /= 1e9; suffix = 'B'; }
19
+ else if (abs >= 1e6) { num /= 1e6; suffix = 'M'; }
20
+ else if (abs >= 1e3) { num /= 1e3; suffix = 'K'; }
21
+ }
22
+
23
+ // Decimal places (default: 0, or 1 if compact with suffix)
24
+ const places = decimals ?? (suffix ? 1 : 0);
25
+
26
+ // Format with or without thousands separators
27
+ let formatted = thousands && !suffix
28
+ ? num.toLocaleString('en-US', { minimumFractionDigits: places, maximumFractionDigits: places })
29
+ : num.toFixed(places);
30
+
31
+ formatted += suffix;
32
+
33
+ // Currency symbol
34
+ if (currency?.symbol) {
35
+ const pos = currency.position ?? 'prefix';
36
+ formatted = pos === 'prefix' ? currency.symbol + formatted : formatted + currency.symbol;
37
+ }
38
+
39
+ return formatted;
40
+ }
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { renderers } from './renderers/index.js';
2
2
  export { loadCSV, parseCSV } from './csv.js';
3
3
  export { slugify, calculatePercentages, getLabelKey, getValueKey, getSeriesNames, escapeHtml } from './utils.js';
4
+ export { formatNumber } from './formatters.js';
@@ -1,4 +1,5 @@
1
1
  import { slugify, escapeHtml, getLabelKey, getValueKey, getSeriesNames } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a donut/pie chart using conic-gradient
@@ -14,7 +15,7 @@ import { slugify, escapeHtml, getLabelKey, getValueKey, getSeriesNames } from '.
14
15
  * @returns {string} - HTML string
15
16
  */
16
17
  export function renderDonut(config) {
17
- const { title, subtitle, data, legend, center, animate } = config;
18
+ const { title, subtitle, data, legend, center, animate, format } = config;
18
19
 
19
20
  if (!data || data.length === 0) {
20
21
  return `<!-- Donut chart: no data provided -->`;
@@ -82,7 +83,8 @@ export function renderDonut(config) {
82
83
  if (center) {
83
84
  const centerValue = center.value === 'total' ? total : center.value;
84
85
  if (centerValue !== undefined) {
85
- html += `<span class="donut-value">${escapeHtml(String(centerValue))}</span>`;
86
+ const displayValue = typeof centerValue === 'number' ? (formatNumber(centerValue, format) || centerValue) : centerValue;
87
+ html += `<span class="donut-value">${escapeHtml(String(displayValue))}</span>`;
86
88
  }
87
89
  if (center.label) {
88
90
  html += `<span class="donut-label">${escapeHtml(center.label)}</span>`;
@@ -1,4 +1,5 @@
1
1
  import { slugify, escapeHtml, getLabelKey, getSeriesNames } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a categorical dot chart (columns with dots at different Y positions)
@@ -14,7 +15,7 @@ import { slugify, escapeHtml, getLabelKey, getSeriesNames } from '../utils.js';
14
15
  * @returns {string} - HTML string
15
16
  */
16
17
  export function renderDot(config) {
17
- const { title, subtitle, data, max, min, legend, animate } = config;
18
+ const { title, subtitle, data, max, min, legend, animate, format } = config;
18
19
 
19
20
  if (!data || data.length === 0) {
20
21
  return `<!-- Dot chart: no data provided -->`;
@@ -71,10 +72,10 @@ export function renderDot(config) {
71
72
  // Y-axis
72
73
  const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
73
74
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
74
- html += `<span class="axis-label">${maxValue}</span>`;
75
+ html += `<span class="axis-label">${formatNumber(maxValue, format) || maxValue}</span>`;
75
76
  const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
76
- html += `<span class="axis-label">${midLabelY}</span>`;
77
- html += `<span class="axis-label">${minValue}</span>`;
77
+ html += `<span class="axis-label">${formatNumber(midLabelY, format) || midLabelY}</span>`;
78
+ html += `<span class="axis-label">${formatNumber(minValue, format) || minValue}</span>`;
78
79
  html += `</div>`;
79
80
 
80
81
  const zeroStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
@@ -97,7 +98,7 @@ export function renderDot(config) {
97
98
 
98
99
  html += `<div class="dot ${colorClass} ${seriesClass}" `;
99
100
  html += `style="--value: ${yPct.toFixed(2)}%" `;
100
- html += `title="${escapeHtml(label)}: ${value} ${escapeHtml(tooltipLabel)}"`;
101
+ html += `title="${escapeHtml(label)}: ${formatNumber(value, format) || value} ${escapeHtml(tooltipLabel)}"`;
101
102
  html += `></div>`;
102
103
  });
103
104
 
@@ -1,21 +1,28 @@
1
1
  import { slugify, escapeHtml } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a scatter plot (continuous X and Y axes)
5
6
  * @param {Object} config - Chart configuration
6
7
  * @param {string} config.title - Chart title
7
8
  * @param {string} [config.subtitle] - Chart subtitle
8
- * @param {Object[]} config.data - Chart data (with label, x, y, and optionally series)
9
+ * @param {Object[]} config.data - Chart data (positional: label, x, y, series)
9
10
  * @param {number} [config.maxX] - Maximum X value (defaults to max in data)
10
11
  * @param {number} [config.maxY] - Maximum Y value (defaults to max in data)
11
12
  * @param {number} [config.minX] - Minimum X value (defaults to min in data or 0)
12
13
  * @param {number} [config.minY] - Minimum Y value (defaults to min in data or 0)
13
14
  * @param {string[]} [config.legend] - Legend labels for series
14
15
  * @param {boolean} [config.animate] - Enable animations
16
+ * @param {string} [config.titleX] - X-axis title (defaults to column name)
17
+ * @param {string} [config.titleY] - Y-axis title (defaults to column name)
15
18
  * @returns {string} - HTML string
16
19
  */
17
20
  export function renderScatter(config) {
18
- const { title, subtitle, data, maxX, maxY, minX, minY, legend, animate } = config;
21
+ const { title, subtitle, data, maxX, maxY, minX, minY, legend, animate, format, titleX, titleY } = config;
22
+
23
+ // Handle nested X/Y format for scatter charts
24
+ const fmtX = format?.x || format || {};
25
+ const fmtY = format?.y || format || {};
19
26
 
20
27
  if (!data || data.length === 0) {
21
28
  return `<!-- Scatter chart: no data provided -->`;
@@ -23,25 +30,24 @@ export function renderScatter(config) {
23
30
 
24
31
  const animateClass = animate ? ' chart-animate' : '';
25
32
 
26
- // Normalize data format
27
- let dots = [];
28
- if (data[0].x !== undefined && data[0].y !== undefined) {
29
- // Direct {label, x, y, series?} format
30
- dots = data.map(item => ({
31
- label: item.label ?? '',
32
- x: typeof item.x === 'number' ? item.x : parseFloat(item.x) || 0,
33
- y: typeof item.y === 'number' ? item.y : parseFloat(item.y) || 0,
34
- series: item.series ?? 'default'
35
- }));
36
- } else if (data[0].value !== undefined) {
37
- // Simple {label, value} format - use index as x, value as y
38
- dots = data.map((item, i) => ({
39
- label: item.label ?? '',
40
- x: i,
41
- y: typeof item.value === 'number' ? item.value : parseFloat(item.value) || 0,
42
- series: 'default'
43
- }));
44
- }
33
+ // Get column keys positionally
34
+ const keys = Object.keys(data[0]);
35
+ const labelKey = keys[0]; // First column: point labels
36
+ const xKey = keys[1]; // Second column: X values
37
+ const yKey = keys[2]; // Third column: Y values
38
+ const seriesKey = keys[3]; // Fourth column (optional): series
39
+
40
+ // Axis titles: explicit config overrides column names
41
+ const xAxisTitle = titleX ?? xKey;
42
+ const yAxisTitle = titleY ?? yKey;
43
+
44
+ // Map data to dots using positional columns
45
+ const dots = data.map(item => ({
46
+ label: item[labelKey] ?? '',
47
+ x: typeof item[xKey] === 'number' ? item[xKey] : parseFloat(item[xKey]) || 0,
48
+ y: typeof item[yKey] === 'number' ? item[yKey] : parseFloat(item[yKey]) || 0,
49
+ series: seriesKey ? (item[seriesKey] ?? 'default') : 'default'
50
+ }));
45
51
 
46
52
  // Calculate bounds
47
53
  const xValues = dots.map(d => d.x);
@@ -99,10 +105,11 @@ export function renderScatter(config) {
99
105
  // Y-axis
100
106
  const yAxisStyle = hasNegativeY ? ` style="--zero-position-y: ${zeroPctY.toFixed(2)}%"` : '';
101
107
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
102
- html += `<span class="axis-label">${calcMaxY}</span>`;
108
+ html += `<span class="axis-label">${formatNumber(calcMaxY, fmtY) || calcMaxY}</span>`;
103
109
  const midLabelY = hasNegativeY ? 0 : Math.round((calcMaxY + calcMinY) / 2);
104
- html += `<span class="axis-label">${midLabelY}</span>`;
105
- html += `<span class="axis-label">${calcMinY}</span>`;
110
+ html += `<span class="axis-label">${formatNumber(midLabelY, fmtY) || midLabelY}</span>`;
111
+ html += `<span class="axis-label">${formatNumber(calcMinY, fmtY) || calcMinY}</span>`;
112
+ html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
106
113
  html += `</div>`;
107
114
 
108
115
  // Container gets zero position variables for axis line CSS
@@ -120,7 +127,9 @@ export function renderScatter(config) {
120
127
  const colorIndex = seriesIndex.get(dot.series) + 1;
121
128
  const colorClass = `chart-color-${colorIndex}`;
122
129
  const seriesClass = `chart-series-${slugify(dot.series)}`;
123
- const tooltipText = dot.label ? `${dot.label}: (${dot.x}, ${dot.y})` : `(${dot.x}, ${dot.y})`;
130
+ const fmtXVal = formatNumber(dot.x, fmtX) || dot.x;
131
+ const fmtYVal = formatNumber(dot.y, fmtY) || dot.y;
132
+ const tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
124
133
 
125
134
  html += `<div class="dot ${colorClass} ${seriesClass}" `;
126
135
  html += `style="--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%" `;
@@ -134,10 +143,11 @@ export function renderScatter(config) {
134
143
  // X-axis
135
144
  const xAxisStyle = hasNegativeX ? ` style="--zero-position-x: ${zeroPctX.toFixed(2)}%"` : '';
136
145
  html += `<div class="chart-x-axis"${xAxisStyle}>`;
137
- html += `<span class="axis-label">${calcMinX}</span>`;
146
+ html += `<span class="axis-label">${formatNumber(calcMinX, fmtX) || calcMinX}</span>`;
138
147
  const midLabelX = hasNegativeX ? 0 : Math.round((calcMaxX + calcMinX) / 2);
139
- html += `<span class="axis-label">${midLabelX}</span>`;
140
- html += `<span class="axis-label">${calcMaxX}</span>`;
148
+ html += `<span class="axis-label">${formatNumber(midLabelX, fmtX) || midLabelX}</span>`;
149
+ html += `<span class="axis-label">${formatNumber(calcMaxX, fmtX) || calcMaxX}</span>`;
150
+ html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
141
151
  html += `</div>`;
142
152
 
143
153
  html += `</div>`;
@@ -1,4 +1,5 @@
1
1
  import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a stacked bar chart (horizontal)
@@ -12,7 +13,7 @@ import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml
12
13
  * @returns {string} - HTML string
13
14
  */
14
15
  export function renderStackedBar(config) {
15
- const { title, subtitle, data, max, legend, animate } = config;
16
+ const { title, subtitle, data, max, legend, animate, format } = config;
16
17
 
17
18
  if (!data || data.length === 0) {
18
19
  return `<!-- Stacked bar chart: no data provided -->`;
@@ -25,6 +26,14 @@ export function renderStackedBar(config) {
25
26
  const legendLabels = legend ?? seriesKeys;
26
27
  const animateClass = animate ? ' chart-animate' : '';
27
28
 
29
+ // Calculate max total across all rows if not provided
30
+ const calculatedMax = max ?? Math.max(...data.map(row => {
31
+ return seriesKeys.reduce((sum, key) => {
32
+ const val = row[key];
33
+ return sum + (typeof val === 'number' ? val : parseFloat(val) || 0);
34
+ }, 0);
35
+ }));
36
+
28
37
  let html = `<figure class="chart chart-stacked-bar${animateClass}">`;
29
38
 
30
39
  if (title) {
@@ -56,13 +65,13 @@ export function renderStackedBar(config) {
56
65
  return typeof val === 'number' ? val : parseFloat(val) || 0;
57
66
  });
58
67
  const total = values.reduce((sum, v) => sum + v, 0);
59
- const percentages = calculatePercentages(values, max);
68
+ const percentages = calculatePercentages(values, calculatedMax);
60
69
  const seriesLabels = legendLabels ?? seriesKeys;
61
70
 
62
71
  html += `<div class="bar-row">`;
63
72
  html += `<span class="bar-label">${escapeHtml(label)}</span>`;
64
73
  html += `<div class="bar-track">`;
65
- html += `<div class="bar-fills" title="${escapeHtml(label)}: ${total}">`;
74
+ html += `<div class="bar-fills" title="${escapeHtml(label)}: ${formatNumber(total, format) || total}">`;
66
75
 
67
76
  seriesKeys.forEach((key, i) => {
68
77
  const pct = percentages[i];
@@ -71,7 +80,7 @@ export function renderStackedBar(config) {
71
80
  const colorClass = `chart-color-${i + 1}`;
72
81
  const seriesClass = `chart-series-${slugify(key)}`;
73
82
  const seriesLabel = seriesLabels[i] ?? key;
74
- html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${value}"></div>`;
83
+ html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}"></div>`;
75
84
  }
76
85
  });
77
86
 
@@ -79,7 +88,7 @@ export function renderStackedBar(config) {
79
88
  html += `</div>`;
80
89
 
81
90
  // Show total value
82
- html += `<span class="bar-value">${total}</span>`;
91
+ html += `<span class="bar-value">${formatNumber(total, format) || total}</span>`;
83
92
  html += `</div>`;
84
93
  });
85
94
 
@@ -1,4 +1,5 @@
1
1
  import { slugify, getLabelKey, getSeriesNames, escapeHtml } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a stacked column chart (vertical)
@@ -13,7 +14,7 @@ import { slugify, getLabelKey, getSeriesNames, escapeHtml } from '../utils.js';
13
14
  * @returns {string} - HTML string
14
15
  */
15
16
  export function renderStackedColumn(config) {
16
- const { title, subtitle, data, max, min, legend, animate } = config;
17
+ const { title, subtitle, data, max, min, legend, animate, format } = config;
17
18
 
18
19
  if (!data || data.length === 0) {
19
20
  return `<!-- Stacked column chart: no data provided -->`;
@@ -81,10 +82,11 @@ export function renderStackedColumn(config) {
81
82
  // Y-axis with --zero-position for label positioning
82
83
  const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
83
84
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
84
- html += `<span class="axis-label">${maxValue}</span>`;
85
+ html += `<span class="axis-label">${formatNumber(maxValue, format) || maxValue}</span>`;
85
86
  const midLabelY = hasNegativeY ? 0 : Math.round(maxValue / 2);
86
- html += `<span class="axis-label">${midLabelY}</span>`;
87
- html += `<span class="axis-label">${hasNegativeY ? minValue : 0}</span>`;
87
+ html += `<span class="axis-label">${formatNumber(midLabelY, format) || midLabelY}</span>`;
88
+ const minLabelY = hasNegativeY ? minValue : 0;
89
+ html += `<span class="axis-label">${formatNumber(minLabelY, format) || minLabelY}</span>`;
88
90
  html += `</div>`;
89
91
 
90
92
  const columnsStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
@@ -115,7 +117,7 @@ export function renderStackedColumn(config) {
115
117
  classes: `column-segment ${colorClass} ${seriesClass}`,
116
118
  bottom: positiveBottom,
117
119
  height: segmentHeight,
118
- title: `${escapeHtml(seriesLabel)}: ${value}`,
120
+ title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}`,
119
121
  isNegative: false
120
122
  });
121
123
  lastPositiveIdx = segments.length - 1;
@@ -126,7 +128,7 @@ export function renderStackedColumn(config) {
126
128
  classes: `column-segment ${colorClass} ${seriesClass} is-negative`,
127
129
  bottom: negativeTop,
128
130
  height: segmentHeight,
129
- title: `${escapeHtml(seriesLabel)}: ${value}`,
131
+ title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}`,
130
132
  isNegative: true
131
133
  });
132
134
  lastNegativeIdx = segments.length - 1;
@@ -161,7 +163,7 @@ export function renderStackedColumn(config) {
161
163
  const endClass = idx === lastIdx ? ' is-stack-end' : '';
162
164
  html += `<div class="column-segment ${colorClass} ${seriesClass}${endClass}" `;
163
165
  html += `style="--value: ${seg.pct.toFixed(2)}%" `;
164
- html += `title="${escapeHtml(seriesLabel)}: ${seg.value}"></div>`;
166
+ html += `title="${escapeHtml(seriesLabel)}: ${formatNumber(seg.value, format) || seg.value}"></div>`;
165
167
  });
166
168
  }
167
169