eleventy-plugin-uncharted 0.1.1 → 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
@@ -1,6 +1,6 @@
1
1
  # Uncharted
2
2
 
3
- A CSS-based charting plugin for Eleventy. Renders charts as pure HTML/CSS with no JavaScript dependencies.
3
+ A CSS-based chart plugin for Eleventy. Renders charts as pure HTML/CSS with no JavaScript dependencies.
4
4
 
5
5
  ## Installation
6
6
 
@@ -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
@@ -110,22 +165,33 @@ charts:
110
165
 
111
166
  ## CSV Format
112
167
 
113
- CSV files use the first column as labels and subsequent columns as data series:
168
+ CSV files use the first column as labels and subsequent columns as data series. The column names can be anything descriptive:
114
169
 
115
170
  ```csv
116
- label,existing,new
171
+ department,existing,new
117
172
  Finance,11,11
118
173
  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
@@ -135,7 +201,7 @@ Stacked column, dot, and scatter charts support negative values. When negative v
135
201
  For stacked columns, positive values stack upward from zero and negative values stack downward:
136
202
 
137
203
  ```csv
138
- label,Cost,Profit
204
+ quarter,Cost,Profit
139
205
  Q1,20,10
140
206
  Q2,25,-10
141
207
  Q3,15,25
@@ -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 {
@@ -201,6 +230,7 @@
201
230
  height: 100%;
202
231
  width: var(--value);
203
232
  transition: width 0.3s ease;
233
+ background-color: var(--color);
204
234
  }
205
235
 
206
236
  .chart-stacked-bar .bar-value {
@@ -243,6 +273,7 @@
243
273
  width: 100%;
244
274
  height: var(--value);
245
275
  transition: height 0.3s ease;
276
+ background-color: var(--color);
246
277
  }
247
278
 
248
279
  .chart-stacked-column .column-labels {
@@ -381,6 +412,7 @@
381
412
  bottom: var(--value);
382
413
  transform: translate(-50%, 50%);
383
414
  cursor: default;
415
+ background-color: var(--color);
384
416
  }
385
417
 
386
418
  .chart-dot .dot[title]:hover {
@@ -443,6 +475,7 @@
443
475
  bottom: var(--value);
444
476
  transform: translate(-50%, 50%);
445
477
  cursor: default;
478
+ background-color: var(--color);
446
479
  }
447
480
 
448
481
  .chart-scatter .dot[title]:hover {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.1.1",
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
- export { slugify, calculatePercentages, getSeriesNames, escapeHtml } from './utils.js';
3
+ export { slugify, calculatePercentages, getLabelKey, getValueKey, getSeriesNames, escapeHtml } from './utils.js';
4
+ export { formatNumber } from './formatters.js';
@@ -1,4 +1,5 @@
1
- import { slugify, escapeHtml } from '../utils.js';
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 } from '../utils.js';
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 -->`;
@@ -22,18 +23,22 @@ export function renderDonut(config) {
22
23
 
23
24
  const animateClass = animate ? ' chart-animate' : '';
24
25
 
25
- // Extract values - support both {label, value} format and series format
26
+ // Get column keys positionally
27
+ const labelKey = getLabelKey(data);
28
+ const valueKey = getValueKey(data);
29
+ const seriesKeys = getSeriesNames(data);
30
+
31
+ // Extract values - support both label/value format and series format
26
32
  let segments = [];
27
- if (data[0].value !== undefined) {
28
- // Direct {label, value} format
33
+ if (seriesKeys.length === 1) {
34
+ // Two columns: first is label, second is value (multiple rows)
29
35
  segments = data.map(item => ({
30
- label: item.label,
31
- value: typeof item.value === 'number' ? item.value : parseFloat(item.value) || 0
36
+ label: item[labelKey],
37
+ value: typeof item[valueKey] === 'number' ? item[valueKey] : parseFloat(item[valueKey]) || 0
32
38
  }));
33
39
  } else {
34
- // Series format - first row only for donut
35
- const seriesNames = Object.keys(data[0]).filter(k => k !== 'label');
36
- segments = seriesNames.map(name => ({
40
+ // Series format - first row only, columns after the first are series
41
+ segments = seriesKeys.map(name => ({
37
42
  label: name,
38
43
  value: typeof data[0][name] === 'number' ? data[0][name] : parseFloat(data[0][name]) || 0
39
44
  }));
@@ -78,7 +83,8 @@ export function renderDonut(config) {
78
83
  if (center) {
79
84
  const centerValue = center.value === 'total' ? total : center.value;
80
85
  if (centerValue !== undefined) {
81
- 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>`;
82
88
  }
83
89
  if (center.label) {
84
90
  html += `<span class="donut-label">${escapeHtml(center.label)}</span>`;
@@ -1,4 +1,5 @@
1
- import { slugify, escapeHtml, getSeriesNames } from '../utils.js';
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,13 +15,14 @@ import { slugify, escapeHtml, 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 -->`;
21
22
  }
22
23
 
23
- // Get series keys from data columns (excluding 'label')
24
+ // Get label key (first column) and series keys (remaining columns)
25
+ const labelKey = getLabelKey(data);
24
26
  const seriesKeys = getSeriesNames(data);
25
27
  const legendLabels = legend ?? seriesKeys;
26
28
  const animateClass = animate ? ' chart-animate' : '';
@@ -70,10 +72,10 @@ export function renderDot(config) {
70
72
  // Y-axis
71
73
  const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
72
74
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
73
- html += `<span class="axis-label">${maxValue}</span>`;
75
+ html += `<span class="axis-label">${formatNumber(maxValue, format) || maxValue}</span>`;
74
76
  const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
75
- html += `<span class="axis-label">${midLabelY}</span>`;
76
- 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>`;
77
79
  html += `</div>`;
78
80
 
79
81
  const zeroStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
@@ -82,7 +84,7 @@ export function renderDot(config) {
82
84
 
83
85
  // Each row becomes a column with dots for each series
84
86
  data.forEach(row => {
85
- const label = row.label ?? '';
87
+ const label = row[labelKey] ?? '';
86
88
 
87
89
  html += `<div class="dot-col">`;
88
90
 
@@ -96,7 +98,7 @@ export function renderDot(config) {
96
98
 
97
99
  html += `<div class="dot ${colorClass} ${seriesClass}" `;
98
100
  html += `style="--value: ${yPct.toFixed(2)}%" `;
99
- html += `title="${escapeHtml(label)}: ${value} ${escapeHtml(tooltipLabel)}"`;
101
+ html += `title="${escapeHtml(label)}: ${formatNumber(value, format) || value} ${escapeHtml(tooltipLabel)}"`;
100
102
  html += `></div>`;
101
103
  });
102
104
 
@@ -110,7 +112,7 @@ export function renderDot(config) {
110
112
  // X-axis labels
111
113
  html += `<div class="dot-labels">`;
112
114
  data.forEach(row => {
113
- const label = row.label ?? '';
115
+ const label = row[labelKey] ?? '';
114
116
  html += `<span class="dot-label">${escapeHtml(label)}</span>`;
115
117
  });
116
118
  html += `</div>`;
@@ -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
- import { slugify, calculatePercentages, getSeriesNames, escapeHtml } from '../utils.js';
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,18 +13,27 @@ import { slugify, calculatePercentages, getSeriesNames, escapeHtml } from '../ut
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 -->`;
19
20
  }
20
21
 
21
- // Get actual data keys from the first row (excluding 'label')
22
+ // Get label key (first column) and series keys (remaining columns)
23
+ const labelKey = getLabelKey(data);
22
24
  const seriesKeys = getSeriesNames(data);
23
25
  // Use legend for display labels, fall back to data keys
24
26
  const legendLabels = legend ?? seriesKeys;
25
27
  const animateClass = animate ? ' chart-animate' : '';
26
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
+
27
37
  let html = `<figure class="chart chart-stacked-bar${animateClass}">`;
28
38
 
29
39
  if (title) {
@@ -49,19 +59,19 @@ export function renderStackedBar(config) {
49
59
  html += `<div class="chart-bars">`;
50
60
 
51
61
  data.forEach(row => {
52
- const label = row.label ?? '';
62
+ const label = row[labelKey] ?? '';
53
63
  const values = seriesKeys.map(key => {
54
64
  const val = row[key];
55
65
  return typeof val === 'number' ? val : parseFloat(val) || 0;
56
66
  });
57
67
  const total = values.reduce((sum, v) => sum + v, 0);
58
- const percentages = calculatePercentages(values, max);
68
+ const percentages = calculatePercentages(values, calculatedMax);
59
69
  const seriesLabels = legendLabels ?? seriesKeys;
60
70
 
61
71
  html += `<div class="bar-row">`;
62
72
  html += `<span class="bar-label">${escapeHtml(label)}</span>`;
63
73
  html += `<div class="bar-track">`;
64
- html += `<div class="bar-fills" title="${escapeHtml(label)}: ${total}">`;
74
+ html += `<div class="bar-fills" title="${escapeHtml(label)}: ${formatNumber(total, format) || total}">`;
65
75
 
66
76
  seriesKeys.forEach((key, i) => {
67
77
  const pct = percentages[i];
@@ -70,7 +80,7 @@ export function renderStackedBar(config) {
70
80
  const colorClass = `chart-color-${i + 1}`;
71
81
  const seriesClass = `chart-series-${slugify(key)}`;
72
82
  const seriesLabel = seriesLabels[i] ?? key;
73
- 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>`;
74
84
  }
75
85
  });
76
86
 
@@ -78,7 +88,7 @@ export function renderStackedBar(config) {
78
88
  html += `</div>`;
79
89
 
80
90
  // Show total value
81
- html += `<span class="bar-value">${total}</span>`;
91
+ html += `<span class="bar-value">${formatNumber(total, format) || total}</span>`;
82
92
  html += `</div>`;
83
93
  });
84
94
 
@@ -1,4 +1,5 @@
1
- import { slugify, getSeriesNames, escapeHtml } from '../utils.js';
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,13 +14,14 @@ import { slugify, 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 -->`;
20
21
  }
21
22
 
22
- // Get actual data keys from the first row (excluding 'label')
23
+ // Get label key (first column) and series keys (remaining columns)
24
+ const labelKey = getLabelKey(data);
23
25
  const seriesKeys = getSeriesNames(data);
24
26
  // Use legend for display labels, fall back to data keys
25
27
  const legendLabels = legend ?? seriesKeys;
@@ -80,17 +82,18 @@ export function renderStackedColumn(config) {
80
82
  // Y-axis with --zero-position for label positioning
81
83
  const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
82
84
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
83
- html += `<span class="axis-label">${maxValue}</span>`;
85
+ html += `<span class="axis-label">${formatNumber(maxValue, format) || maxValue}</span>`;
84
86
  const midLabelY = hasNegativeY ? 0 : Math.round(maxValue / 2);
85
- html += `<span class="axis-label">${midLabelY}</span>`;
86
- 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>`;
87
90
  html += `</div>`;
88
91
 
89
92
  const columnsStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
90
93
  html += `<div class="chart-columns"${columnsStyle}>`;
91
94
 
92
95
  data.forEach(row => {
93
- const label = row.label ?? '';
96
+ const label = row[labelKey] ?? '';
94
97
  html += `<div class="column-track" title="${escapeHtml(label)}">`;
95
98
 
96
99
  if (hasNegativeY) {
@@ -114,7 +117,7 @@ export function renderStackedColumn(config) {
114
117
  classes: `column-segment ${colorClass} ${seriesClass}`,
115
118
  bottom: positiveBottom,
116
119
  height: segmentHeight,
117
- title: `${escapeHtml(seriesLabel)}: ${value}`,
120
+ title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}`,
118
121
  isNegative: false
119
122
  });
120
123
  lastPositiveIdx = segments.length - 1;
@@ -125,7 +128,7 @@ export function renderStackedColumn(config) {
125
128
  classes: `column-segment ${colorClass} ${seriesClass} is-negative`,
126
129
  bottom: negativeTop,
127
130
  height: segmentHeight,
128
- title: `${escapeHtml(seriesLabel)}: ${value}`,
131
+ title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}`,
129
132
  isNegative: true
130
133
  });
131
134
  lastNegativeIdx = segments.length - 1;
@@ -160,7 +163,7 @@ export function renderStackedColumn(config) {
160
163
  const endClass = idx === lastIdx ? ' is-stack-end' : '';
161
164
  html += `<div class="column-segment ${colorClass} ${seriesClass}${endClass}" `;
162
165
  html += `style="--value: ${seg.pct.toFixed(2)}%" `;
163
- html += `title="${escapeHtml(seriesLabel)}: ${seg.value}"></div>`;
166
+ html += `title="${escapeHtml(seriesLabel)}: ${formatNumber(seg.value, format) || seg.value}"></div>`;
164
167
  });
165
168
  }
166
169
 
@@ -173,7 +176,7 @@ export function renderStackedColumn(config) {
173
176
  // X-axis labels
174
177
  html += `<div class="column-labels">`;
175
178
  data.forEach(row => {
176
- const label = row.label ?? '';
179
+ const label = row[labelKey] ?? '';
177
180
  html += `<span class="column-label">${escapeHtml(label)}</span>`;
178
181
  });
179
182
  html += `</div>`;
package/src/utils.js CHANGED
@@ -25,13 +25,33 @@ export function calculatePercentages(values, max) {
25
25
  }
26
26
 
27
27
  /**
28
- * Extract series names from CSV data (all columns except 'label')
28
+ * Get the label key (first column name) from CSV data
29
+ * @param {Object[]} data - Array of data objects
30
+ * @returns {string|undefined} - The first column name, or undefined if no data
31
+ */
32
+ export function getLabelKey(data) {
33
+ if (!data || data.length === 0) return undefined;
34
+ return Object.keys(data[0])[0];
35
+ }
36
+
37
+ /**
38
+ * Get the value key (second column name) from CSV data
39
+ * @param {Object[]} data - Array of data objects
40
+ * @returns {string|undefined} - The second column name, or undefined if no data
41
+ */
42
+ export function getValueKey(data) {
43
+ if (!data || data.length === 0) return undefined;
44
+ return Object.keys(data[0])[1];
45
+ }
46
+
47
+ /**
48
+ * Extract series names from CSV data (all columns except the first)
29
49
  * @param {Object[]} data - Array of data objects
30
50
  * @returns {string[]} - Array of series names
31
51
  */
32
52
  export function getSeriesNames(data) {
33
53
  if (!data || data.length === 0) return [];
34
- return Object.keys(data[0]).filter(key => key !== 'label');
54
+ return Object.keys(data[0]).slice(1);
35
55
  }
36
56
 
37
57
  /**