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 +74 -5
- package/css/uncharted.css +40 -11
- package/package.json +1 -1
- package/src/csv.js +47 -2
- package/src/formatters.js +40 -0
- package/src/index.js +1 -0
- package/src/renderers/donut.js +4 -2
- package/src/renderers/dot.js +6 -5
- package/src/renderers/scatter.js +38 -28
- package/src/renderers/stacked-bar.js +14 -5
- package/src/renderers/stacked-column.js +9 -7
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,
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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: #
|
|
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:
|
|
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:
|
|
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:
|
|
172
|
-
|
|
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
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]
|
|
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]
|
|
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
package/src/renderers/donut.js
CHANGED
|
@@ -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
|
-
|
|
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>`;
|
package/src/renderers/dot.js
CHANGED
|
@@ -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
|
|
package/src/renderers/scatter.js
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|