eleventy-plugin-uncharted 0.6.2 → 0.7.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 +1 -1
- package/eleventy.config.js +11 -2
- package/package.json +3 -3
- package/src/columns.js +300 -0
- package/src/config.js +260 -0
- package/src/renderers/donut.js +39 -24
- package/src/renderers/dot.js +34 -18
- package/src/renderers/sankey.js +14 -10
- package/src/renderers/scatter.js +40 -31
- package/src/renderers/stacked-bar.js +31 -17
- package/src/renderers/stacked-column.js +39 -23
package/README.md
CHANGED
|
@@ -39,4 +39,4 @@ charts:
|
|
|
39
39
|
|
|
40
40
|
Chart types: `donut`, `stacked-bar`, `stacked-column`, `dot`, `scatter`
|
|
41
41
|
|
|
42
|
-
See the [documentation](https://uncharted.
|
|
42
|
+
See the [documentation](https://uncharted.seanlunsford.com/) for configuration options, styling, animations, and more.
|
package/eleventy.config.js
CHANGED
|
@@ -2,6 +2,8 @@ import path from 'path';
|
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { renderers } from './src/renderers/index.js';
|
|
4
4
|
import { loadCSV } from './src/csv.js';
|
|
5
|
+
import { normalizeConfig } from './src/config.js';
|
|
6
|
+
import { resolveColumns } from './src/columns.js';
|
|
5
7
|
|
|
6
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
9
|
|
|
@@ -122,13 +124,20 @@ export default function(eleventyConfig, options = {}) {
|
|
|
122
124
|
downloadDataUrl = normalizedDataPath + chartConfig.file;
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
// Normalize configuration (handles deprecated keys, axis config)
|
|
128
|
+
const normalizedConfig = normalizeConfig(chartConfig, chartId);
|
|
129
|
+
|
|
130
|
+
// Resolve column mappings
|
|
131
|
+
const columns = resolveColumns(normalizedConfig, data, chartType);
|
|
132
|
+
|
|
125
133
|
return renderer({
|
|
126
|
-
...
|
|
134
|
+
...normalizedConfig,
|
|
127
135
|
id: chartId,
|
|
128
136
|
data,
|
|
129
137
|
animate,
|
|
130
138
|
downloadData,
|
|
131
|
-
downloadDataUrl
|
|
139
|
+
downloadDataUrl,
|
|
140
|
+
_columns: columns
|
|
132
141
|
});
|
|
133
142
|
});
|
|
134
143
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eleventy-plugin-uncharted",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"url": "https://github.com/slunsford/uncharted/issues"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@11ty/eleventy": ">=2.0.0-0"
|
|
35
|
+
"@11ty/eleventy": ">=2.0.0 || >=4.0.0-0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@11ty/eleventy": "^3.0.0"
|
|
38
|
+
"@11ty/eleventy": "^3.0.0 || ^4.0.0-0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/columns.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column resolution for Uncharted
|
|
3
|
+
* Handles explicit column mapping and implicit detection
|
|
4
|
+
* Supports both new unified schema (x.column, y.columns) and deprecated columns key
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseAxisConfig } from './config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve column mappings for chart data
|
|
11
|
+
*
|
|
12
|
+
* New unified schema (takes precedence):
|
|
13
|
+
* - x: { column: "month" } or x: "month"
|
|
14
|
+
* - y: { columns: ["a", "b"] } or y: { columns: { a: "Label A" } }
|
|
15
|
+
*
|
|
16
|
+
* Deprecated schema (fallback):
|
|
17
|
+
* - columns: { x: "month", y: ["a", "b"] }
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} config - Chart configuration with optional columns mapping
|
|
20
|
+
* @param {Object[]} data - Chart data array
|
|
21
|
+
* @param {string} chartType - Chart type for context-aware defaults
|
|
22
|
+
* @returns {Object} - Resolved column keys with labels
|
|
23
|
+
*/
|
|
24
|
+
export function resolveColumns(config, data, chartType) {
|
|
25
|
+
if (!data || data.length === 0) {
|
|
26
|
+
return {
|
|
27
|
+
label: undefined,
|
|
28
|
+
x: undefined,
|
|
29
|
+
y: undefined,
|
|
30
|
+
xLabels: {},
|
|
31
|
+
yLabels: {},
|
|
32
|
+
series: undefined,
|
|
33
|
+
seriesTitle: undefined,
|
|
34
|
+
size: undefined,
|
|
35
|
+
sizeTitle: undefined,
|
|
36
|
+
source: undefined,
|
|
37
|
+
target: undefined,
|
|
38
|
+
value: undefined,
|
|
39
|
+
valueFormat: undefined,
|
|
40
|
+
values: []
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const keys = Object.keys(data[0]);
|
|
45
|
+
const deprecatedColumns = config.columns || {};
|
|
46
|
+
|
|
47
|
+
// Helper to find column by name (case-insensitive)
|
|
48
|
+
const findKey = name => keys.find(k => k.toLowerCase() === name.toLowerCase()) || null;
|
|
49
|
+
|
|
50
|
+
// Helper to validate that a column exists
|
|
51
|
+
const validateColumn = (configKey, configValue) => {
|
|
52
|
+
if (configValue === undefined) return undefined;
|
|
53
|
+
|
|
54
|
+
// Handle array of column names
|
|
55
|
+
if (Array.isArray(configValue)) {
|
|
56
|
+
const valid = configValue.filter(v => keys.includes(v));
|
|
57
|
+
if (valid.length !== configValue.length) {
|
|
58
|
+
const missing = configValue.filter(v => !keys.includes(v));
|
|
59
|
+
console.warn(`[uncharted] ${configKey}: columns not found in data: ${missing.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
return valid.length > 0 ? valid : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle single column name
|
|
65
|
+
if (!keys.includes(configValue)) {
|
|
66
|
+
console.warn(`[uncharted] ${configKey}: column "${configValue}" not found in data`);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return configValue;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Parse axis configs using the new helper
|
|
73
|
+
const xConfig = parseAxisConfig(config.x);
|
|
74
|
+
const yConfig = parseAxisConfig(config.y);
|
|
75
|
+
const labelConfig = parseAxisConfig(config.label);
|
|
76
|
+
const seriesConfig = parseAxisConfig(config.series);
|
|
77
|
+
const sizeConfig = parseAxisConfig(config.size);
|
|
78
|
+
const sourceConfig = parseAxisConfig(config.source);
|
|
79
|
+
const targetConfig = parseAxisConfig(config.target);
|
|
80
|
+
const valueConfig = parseAxisConfig(config.value);
|
|
81
|
+
|
|
82
|
+
// Resolve each column role
|
|
83
|
+
const resolved = {
|
|
84
|
+
label: undefined,
|
|
85
|
+
x: undefined,
|
|
86
|
+
y: undefined,
|
|
87
|
+
xLabels: {},
|
|
88
|
+
yLabels: {},
|
|
89
|
+
series: undefined,
|
|
90
|
+
seriesTitle: undefined,
|
|
91
|
+
size: undefined,
|
|
92
|
+
sizeTitle: undefined,
|
|
93
|
+
source: undefined,
|
|
94
|
+
target: undefined,
|
|
95
|
+
value: undefined,
|
|
96
|
+
valueFormat: undefined,
|
|
97
|
+
values: []
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Chart-type specific resolution
|
|
101
|
+
if (chartType === 'scatter') {
|
|
102
|
+
// Scatter charts use x, y, label, series, size columns
|
|
103
|
+
// New schema: x.column, y.column, label.column, series.column, size.column
|
|
104
|
+
// Deprecated: columns.x, columns.y, columns.label, columns.series, columns.size
|
|
105
|
+
|
|
106
|
+
// X column
|
|
107
|
+
if (xConfig?.columns?.length) {
|
|
108
|
+
resolved.x = validateColumn('x.column', xConfig.columns[0]);
|
|
109
|
+
} else if (deprecatedColumns.x) {
|
|
110
|
+
resolved.x = validateColumn('columns.x', deprecatedColumns.x);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Y column
|
|
114
|
+
if (yConfig?.columns?.length) {
|
|
115
|
+
resolved.y = validateColumn('y.column', yConfig.columns[0]);
|
|
116
|
+
} else if (deprecatedColumns.y) {
|
|
117
|
+
resolved.y = validateColumn('columns.y', deprecatedColumns.y);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Label column (point identifier)
|
|
121
|
+
if (labelConfig?.columns?.length) {
|
|
122
|
+
resolved.label = validateColumn('label.column', labelConfig.columns[0]);
|
|
123
|
+
} else if (deprecatedColumns.label) {
|
|
124
|
+
resolved.label = validateColumn('columns.label', deprecatedColumns.label);
|
|
125
|
+
} else {
|
|
126
|
+
resolved.label = keys[0];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Series column (for coloring)
|
|
130
|
+
if (seriesConfig?.columns?.length) {
|
|
131
|
+
resolved.series = validateColumn('series.column', seriesConfig.columns[0]);
|
|
132
|
+
resolved.seriesTitle = seriesConfig.title;
|
|
133
|
+
} else if (deprecatedColumns.series) {
|
|
134
|
+
resolved.series = validateColumn('columns.series', deprecatedColumns.series);
|
|
135
|
+
resolved.seriesTitle = config.legendTitle; // deprecated
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Size column
|
|
139
|
+
if (sizeConfig?.columns?.length) {
|
|
140
|
+
resolved.size = validateColumn('size.column', sizeConfig.columns[0]);
|
|
141
|
+
resolved.sizeTitle = sizeConfig.title;
|
|
142
|
+
} else if (deprecatedColumns.size) {
|
|
143
|
+
resolved.size = validateColumn('columns.size', deprecatedColumns.size);
|
|
144
|
+
resolved.sizeTitle = config.sizeTitle; // deprecated
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Implicit detection for scatter if not explicitly specified
|
|
148
|
+
if (!resolved.x || !resolved.y) {
|
|
149
|
+
const namedX = findKey('x');
|
|
150
|
+
const namedY = findKey('y');
|
|
151
|
+
|
|
152
|
+
if (namedX && namedY) {
|
|
153
|
+
resolved.x = resolved.x ?? namedX;
|
|
154
|
+
resolved.y = resolved.y ?? namedY;
|
|
155
|
+
} else {
|
|
156
|
+
resolved.x = resolved.x ?? keys[1];
|
|
157
|
+
resolved.y = resolved.y ?? keys[2];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Implicit series/size detection
|
|
162
|
+
if (!resolved.series) {
|
|
163
|
+
resolved.series = findKey('series');
|
|
164
|
+
}
|
|
165
|
+
if (!resolved.size) {
|
|
166
|
+
resolved.size = findKey('size');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
} else if (chartType === 'sankey') {
|
|
170
|
+
// Sankey charts use source, target, value columns
|
|
171
|
+
// New schema: source.column, target.column, value.column
|
|
172
|
+
// Deprecated: columns.source, columns.target, columns.value
|
|
173
|
+
|
|
174
|
+
if (sourceConfig?.columns?.length) {
|
|
175
|
+
resolved.source = validateColumn('source.column', sourceConfig.columns[0]);
|
|
176
|
+
} else if (deprecatedColumns.source) {
|
|
177
|
+
resolved.source = validateColumn('columns.source', deprecatedColumns.source);
|
|
178
|
+
} else {
|
|
179
|
+
resolved.source = keys[0];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (targetConfig?.columns?.length) {
|
|
183
|
+
resolved.target = validateColumn('target.column', targetConfig.columns[0]);
|
|
184
|
+
} else if (deprecatedColumns.target) {
|
|
185
|
+
resolved.target = validateColumn('columns.target', deprecatedColumns.target);
|
|
186
|
+
} else {
|
|
187
|
+
resolved.target = keys[1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (valueConfig?.columns?.length) {
|
|
191
|
+
resolved.value = validateColumn('value.column', valueConfig.columns[0]);
|
|
192
|
+
resolved.valueFormat = valueConfig.format;
|
|
193
|
+
} else if (deprecatedColumns.value) {
|
|
194
|
+
resolved.value = validateColumn('columns.value', deprecatedColumns.value);
|
|
195
|
+
} else {
|
|
196
|
+
resolved.value = keys[2];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
} else if (chartType === 'donut') {
|
|
200
|
+
// Donut charts use label, value columns
|
|
201
|
+
// New schema: label.column, value.column
|
|
202
|
+
// Deprecated: columns.label, columns.value (or implicit first/second columns)
|
|
203
|
+
|
|
204
|
+
if (labelConfig?.columns?.length) {
|
|
205
|
+
resolved.label = validateColumn('label.column', labelConfig.columns[0]);
|
|
206
|
+
resolved.yLabels = labelConfig.labels || {};
|
|
207
|
+
} else if (deprecatedColumns.label) {
|
|
208
|
+
resolved.label = validateColumn('columns.label', deprecatedColumns.label);
|
|
209
|
+
} else {
|
|
210
|
+
resolved.label = keys[0];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (valueConfig?.columns?.length) {
|
|
214
|
+
resolved.values = validateColumn('value.column', valueConfig.columns) || [];
|
|
215
|
+
if (!Array.isArray(resolved.values)) {
|
|
216
|
+
resolved.values = [resolved.values];
|
|
217
|
+
}
|
|
218
|
+
resolved.valueFormat = valueConfig.format;
|
|
219
|
+
} else if (deprecatedColumns.value) {
|
|
220
|
+
const val = validateColumn('columns.value', deprecatedColumns.value);
|
|
221
|
+
resolved.values = val ? [val] : [];
|
|
222
|
+
} else {
|
|
223
|
+
// Default: all columns except label
|
|
224
|
+
resolved.values = keys.filter(k => k !== resolved.label);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
} else if (chartType === 'stacked-bar') {
|
|
228
|
+
// Stacked bar: y = categories (left side), x = value series (bars extend right)
|
|
229
|
+
// New schema: y.column (categories), x.columns (values with labels)
|
|
230
|
+
// Deprecated: columns.y (or label), columns.x (or y as values)
|
|
231
|
+
|
|
232
|
+
// Category column (on Y axis for stacked-bar)
|
|
233
|
+
if (yConfig?.columns?.length) {
|
|
234
|
+
resolved.label = validateColumn('y.column', yConfig.columns[0]);
|
|
235
|
+
} else if (deprecatedColumns.label) {
|
|
236
|
+
resolved.label = validateColumn('columns.label', deprecatedColumns.label);
|
|
237
|
+
} else {
|
|
238
|
+
resolved.label = keys[0];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Value columns (extend along X axis)
|
|
242
|
+
if (xConfig?.columns?.length) {
|
|
243
|
+
resolved.values = validateColumn('x.columns', xConfig.columns) || [];
|
|
244
|
+
resolved.xLabels = xConfig.labels || {};
|
|
245
|
+
} else if (deprecatedColumns.y) {
|
|
246
|
+
// Deprecated: columns.y was used for value columns in stacked-bar
|
|
247
|
+
const explicitY = validateColumn('columns.y', deprecatedColumns.y);
|
|
248
|
+
resolved.values = Array.isArray(explicitY) ? explicitY : (explicitY ? [explicitY] : []);
|
|
249
|
+
} else {
|
|
250
|
+
// Implicit: all columns except label are values
|
|
251
|
+
resolved.values = keys.filter(k => k !== resolved.label);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
} else {
|
|
255
|
+
// Standard charts (dot, line, stacked-column): x = categories, y = multi-series values
|
|
256
|
+
// New schema: x.column (categories), y.columns (values with labels)
|
|
257
|
+
// Deprecated: columns.label (or implicit first), columns.y
|
|
258
|
+
|
|
259
|
+
// Label/category column (X axis)
|
|
260
|
+
if (xConfig?.columns?.length) {
|
|
261
|
+
resolved.label = validateColumn('x.column', xConfig.columns[0]);
|
|
262
|
+
} else if (deprecatedColumns.label) {
|
|
263
|
+
resolved.label = validateColumn('columns.label', deprecatedColumns.label);
|
|
264
|
+
} else {
|
|
265
|
+
resolved.label = keys[0];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Value columns (Y axis)
|
|
269
|
+
if (yConfig?.columns?.length) {
|
|
270
|
+
resolved.values = validateColumn('y.columns', yConfig.columns) || [];
|
|
271
|
+
resolved.yLabels = yConfig.labels || {};
|
|
272
|
+
} else if (deprecatedColumns.y) {
|
|
273
|
+
const explicitY = validateColumn('columns.y', deprecatedColumns.y);
|
|
274
|
+
resolved.values = Array.isArray(explicitY) ? explicitY : (explicitY ? [explicitY] : []);
|
|
275
|
+
} else {
|
|
276
|
+
// Implicit: all columns except label are values
|
|
277
|
+
resolved.values = keys.filter(k => k !== resolved.label);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return resolved;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get series names from resolved columns
|
|
286
|
+
* @param {Object} resolved - Resolved columns from resolveColumns()
|
|
287
|
+
* @returns {string[]} - Array of series/value column names
|
|
288
|
+
*/
|
|
289
|
+
export function getResolvedSeriesNames(resolved) {
|
|
290
|
+
return resolved.values || [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get the label key from resolved columns
|
|
295
|
+
* @param {Object} resolved - Resolved columns from resolveColumns()
|
|
296
|
+
* @returns {string|undefined} - Label column name
|
|
297
|
+
*/
|
|
298
|
+
export function getResolvedLabelKey(resolved) {
|
|
299
|
+
return resolved.label;
|
|
300
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration normalization layer for Uncharted
|
|
3
|
+
* Transforms various config formats into a standardized structure
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Deprecated keys mapping for deprecation warnings
|
|
7
|
+
const DEPRECATED_KEYS = {
|
|
8
|
+
maxX: 'x.max',
|
|
9
|
+
minX: 'x.min',
|
|
10
|
+
maxY: 'y.max',
|
|
11
|
+
minY: 'y.min',
|
|
12
|
+
titleX: 'x.title',
|
|
13
|
+
titleY: 'y.title',
|
|
14
|
+
legendTitle: 'series.title',
|
|
15
|
+
sizeTitle: 'size.title',
|
|
16
|
+
rotateLabels: 'x.rotateLabels'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Track which deprecation warnings have been shown to avoid spam
|
|
20
|
+
const warnedConfigs = new Set();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Log a deprecation warning (once per config/key combination)
|
|
24
|
+
* @param {string} chartId - Chart identifier
|
|
25
|
+
* @param {string} oldKey - Deprecated key name
|
|
26
|
+
* @param {string} newKey - New key path
|
|
27
|
+
*/
|
|
28
|
+
function warnDeprecation(chartId, oldKey, newKey) {
|
|
29
|
+
const key = `${chartId}:${oldKey}`;
|
|
30
|
+
if (warnedConfigs.has(key)) return;
|
|
31
|
+
warnedConfigs.add(key);
|
|
32
|
+
console.warn(`[uncharted] Chart "${chartId}": "${oldKey}" is deprecated, use "${newKey}" instead`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize chart configuration to standardized structure
|
|
37
|
+
*
|
|
38
|
+
* Precedence for axis properties (highest to lowest):
|
|
39
|
+
* 1. x.max / y.max (new nested format)
|
|
40
|
+
* 2. maxX / maxY (deprecated suffixed format)
|
|
41
|
+
* 3. max (global fallback)
|
|
42
|
+
*
|
|
43
|
+
* Format precedence:
|
|
44
|
+
* 1. x.format / y.format (new nested format)
|
|
45
|
+
* 2. format.x / format.y (deprecated nested format)
|
|
46
|
+
* 3. format (global fallback)
|
|
47
|
+
*
|
|
48
|
+
* Column mapping precedence:
|
|
49
|
+
* 1. x.column / x.columns / y.column / y.columns (new unified format)
|
|
50
|
+
* 2. columns.x / columns.y (deprecated format)
|
|
51
|
+
*
|
|
52
|
+
* Legend precedence:
|
|
53
|
+
* 1. y.columns: { key: "Label" } or x.columns (for stacked-bar)
|
|
54
|
+
* 2. legend: ["Label1", "Label2"] (deprecated)
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} config - Raw chart configuration
|
|
57
|
+
* @param {string} [chartId] - Chart ID for deprecation warnings
|
|
58
|
+
* @returns {Object} - Normalized configuration
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeConfig(config, chartId = 'unknown') {
|
|
61
|
+
if (!config) return config;
|
|
62
|
+
|
|
63
|
+
const normalized = { ...config };
|
|
64
|
+
|
|
65
|
+
// Build x axis config
|
|
66
|
+
normalized.x = buildAxisConfig('x', config, chartId);
|
|
67
|
+
|
|
68
|
+
// Build y axis config
|
|
69
|
+
normalized.y = buildAxisConfig('y', config, chartId);
|
|
70
|
+
|
|
71
|
+
// Warn for deprecated columns key
|
|
72
|
+
if (config.columns) {
|
|
73
|
+
warnDeprecation(chartId, 'columns', 'x.column / y.columns');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Warn for deprecated legend array (when used for labels, not boolean)
|
|
77
|
+
if (Array.isArray(config.legend)) {
|
|
78
|
+
warnDeprecation(chartId, 'legend (array)', 'y.columns: { key: "Label" }');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Warn for deprecated scatter-specific keys
|
|
82
|
+
if (config.legendTitle !== undefined) {
|
|
83
|
+
warnDeprecation(chartId, 'legendTitle', 'series.title');
|
|
84
|
+
}
|
|
85
|
+
if (config.sizeTitle !== undefined) {
|
|
86
|
+
warnDeprecation(chartId, 'sizeTitle', 'size.title');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Clean up deprecated top-level keys (keep them for backwards compat but don't pass to renderers)
|
|
90
|
+
// The renderers will use normalized.x and normalized.y instead
|
|
91
|
+
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build normalized axis configuration
|
|
97
|
+
* @param {'x' | 'y'} axis - Axis name
|
|
98
|
+
* @param {Object} config - Raw config
|
|
99
|
+
* @param {string} chartId - Chart ID for warnings
|
|
100
|
+
* @returns {Object} - Normalized axis config
|
|
101
|
+
*/
|
|
102
|
+
function buildAxisConfig(axis, config, chartId) {
|
|
103
|
+
const axisUpper = axis.toUpperCase();
|
|
104
|
+
const existingAxisConfig = config[axis] || {};
|
|
105
|
+
|
|
106
|
+
// Start with existing axis config if present
|
|
107
|
+
const axisConfig = { ...existingAxisConfig };
|
|
108
|
+
|
|
109
|
+
// max: x.max > maxX > max
|
|
110
|
+
if (axisConfig.max === undefined) {
|
|
111
|
+
const deprecatedKey = `max${axisUpper}`;
|
|
112
|
+
if (config[deprecatedKey] !== undefined) {
|
|
113
|
+
warnDeprecation(chartId, deprecatedKey, `${axis}.max`);
|
|
114
|
+
axisConfig.max = config[deprecatedKey];
|
|
115
|
+
} else if (config.max !== undefined) {
|
|
116
|
+
axisConfig.max = config.max;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// min: x.min > minX > min
|
|
121
|
+
if (axisConfig.min === undefined) {
|
|
122
|
+
const deprecatedKey = `min${axisUpper}`;
|
|
123
|
+
if (config[deprecatedKey] !== undefined) {
|
|
124
|
+
warnDeprecation(chartId, deprecatedKey, `${axis}.min`);
|
|
125
|
+
axisConfig.min = config[deprecatedKey];
|
|
126
|
+
} else if (config.min !== undefined) {
|
|
127
|
+
axisConfig.min = config.min;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// title: x.title > titleX
|
|
132
|
+
if (axisConfig.title === undefined) {
|
|
133
|
+
const deprecatedKey = `title${axisUpper}`;
|
|
134
|
+
if (config[deprecatedKey] !== undefined) {
|
|
135
|
+
warnDeprecation(chartId, deprecatedKey, `${axis}.title`);
|
|
136
|
+
axisConfig.title = config[deprecatedKey];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// format: x.format > format.x > format
|
|
141
|
+
if (axisConfig.format === undefined) {
|
|
142
|
+
const globalFormat = config.format || {};
|
|
143
|
+
if (globalFormat[axis] !== undefined) {
|
|
144
|
+
// format.x or format.y (deprecated nested format)
|
|
145
|
+
warnDeprecation(chartId, `format.${axis}`, `${axis}.format`);
|
|
146
|
+
axisConfig.format = globalFormat[axis];
|
|
147
|
+
} else if (typeof globalFormat === 'object' && !globalFormat.x && !globalFormat.y) {
|
|
148
|
+
// Global format object (no x/y nesting) - use as fallback
|
|
149
|
+
axisConfig.format = globalFormat;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return axisConfig;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get effective max value for an axis
|
|
158
|
+
* @param {Object} normalizedConfig - Normalized config
|
|
159
|
+
* @param {'x' | 'y'} axis - Axis name
|
|
160
|
+
* @returns {number|undefined} - Max value or undefined
|
|
161
|
+
*/
|
|
162
|
+
export function getAxisMax(normalizedConfig, axis) {
|
|
163
|
+
return normalizedConfig[axis]?.max;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get effective min value for an axis
|
|
168
|
+
* @param {Object} normalizedConfig - Normalized config
|
|
169
|
+
* @param {'x' | 'y'} axis - Axis name
|
|
170
|
+
* @returns {number|undefined} - Min value or undefined
|
|
171
|
+
*/
|
|
172
|
+
export function getAxisMin(normalizedConfig, axis) {
|
|
173
|
+
return normalizedConfig[axis]?.min;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get effective title for an axis
|
|
178
|
+
* @param {Object} normalizedConfig - Normalized config
|
|
179
|
+
* @param {'x' | 'y'} axis - Axis name
|
|
180
|
+
* @param {string} [fallback] - Fallback title (e.g., column name)
|
|
181
|
+
* @returns {string} - Axis title
|
|
182
|
+
*/
|
|
183
|
+
export function getAxisTitle(normalizedConfig, axis, fallback = '') {
|
|
184
|
+
return normalizedConfig[axis]?.title ?? fallback;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get effective format config for an axis
|
|
189
|
+
* @param {Object} normalizedConfig - Normalized config
|
|
190
|
+
* @param {'x' | 'y'} axis - Axis name
|
|
191
|
+
* @returns {Object} - Format config
|
|
192
|
+
*/
|
|
193
|
+
export function getAxisFormat(normalizedConfig, axis) {
|
|
194
|
+
return normalizedConfig[axis]?.format || normalizedConfig.format || {};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parse axis configuration with column definitions
|
|
199
|
+
* Handles shorthand and full formats:
|
|
200
|
+
* - String: "column" -> { columns: ["column"], labels: {} }
|
|
201
|
+
* - Array: ["a", "b"] -> { columns: ["a", "b"], labels: {} }
|
|
202
|
+
* - Object with column: { column: "x" } -> { columns: ["x"], labels: {}, ...rest }
|
|
203
|
+
* - Object with columns array: { columns: ["a", "b"] } -> { columns: ["a", "b"], labels: {}, ...rest }
|
|
204
|
+
* - Object with columns object: { columns: { a: "Label A" } } -> { columns: ["a"], labels: { a: "Label A" }, ...rest }
|
|
205
|
+
*
|
|
206
|
+
* @param {string|string[]|Object} axisConfig - Axis config in any supported format
|
|
207
|
+
* @returns {{ columns: string[], labels: object, column?: string, title?: string, ... }|null}
|
|
208
|
+
*/
|
|
209
|
+
export function parseAxisConfig(axisConfig) {
|
|
210
|
+
if (axisConfig === undefined || axisConfig === null) return null;
|
|
211
|
+
|
|
212
|
+
// Handle shorthand: y: "revenue" or y: ["a", "b"]
|
|
213
|
+
if (typeof axisConfig === 'string') {
|
|
214
|
+
return { column: axisConfig, columns: [axisConfig], labels: {} };
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(axisConfig)) {
|
|
217
|
+
return { columns: axisConfig, labels: {} };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Handle object format
|
|
221
|
+
const { column, columns, ...rest } = axisConfig;
|
|
222
|
+
|
|
223
|
+
// Parse columns definition
|
|
224
|
+
let parsedColumns = [];
|
|
225
|
+
let labels = {};
|
|
226
|
+
|
|
227
|
+
if (column) {
|
|
228
|
+
parsedColumns = [column];
|
|
229
|
+
} else if (columns) {
|
|
230
|
+
if (typeof columns === 'string') {
|
|
231
|
+
parsedColumns = [columns];
|
|
232
|
+
} else if (Array.isArray(columns)) {
|
|
233
|
+
parsedColumns = columns;
|
|
234
|
+
} else if (typeof columns === 'object') {
|
|
235
|
+
parsedColumns = Object.keys(columns);
|
|
236
|
+
labels = columns; // { columnName: "Display Label" }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { columns: parsedColumns, labels, column, ...rest };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get rotateLabels setting from axis config or deprecated top-level
|
|
245
|
+
* @param {Object} config - Normalized config
|
|
246
|
+
* @param {string} chartId - Chart ID for deprecation warnings
|
|
247
|
+
* @returns {boolean} - Whether to rotate labels
|
|
248
|
+
*/
|
|
249
|
+
export function getRotateLabels(config, chartId) {
|
|
250
|
+
// New schema: x.rotateLabels
|
|
251
|
+
if (config.x?.rotateLabels !== undefined) {
|
|
252
|
+
return config.x.rotateLabels;
|
|
253
|
+
}
|
|
254
|
+
// Deprecated: top-level rotateLabels
|
|
255
|
+
if (config.rotateLabels !== undefined) {
|
|
256
|
+
warnDeprecation(chartId, 'rotateLabels', 'x.rotateLabels');
|
|
257
|
+
return config.rotateLabels;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
package/src/renderers/donut.js
CHANGED
|
@@ -3,7 +3,7 @@ import { formatNumber } from '../formatters.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Render a donut/pie chart using conic-gradient
|
|
6
|
-
* @param {Object} config - Chart configuration
|
|
6
|
+
* @param {Object} config - Chart configuration (normalized)
|
|
7
7
|
* @param {string} config.title - Chart title
|
|
8
8
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
9
|
* @param {Object[]} config.data - Chart data (with label and value properties)
|
|
@@ -13,10 +13,11 @@ import { formatNumber } from '../formatters.js';
|
|
|
13
13
|
* @param {string} [config.center.label] - Label below the value
|
|
14
14
|
* @param {boolean} [config.animate] - Enable animations
|
|
15
15
|
* @param {boolean} [config.showPercentages] - Show percentages instead of values in legend
|
|
16
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
16
17
|
* @returns {string} - HTML string
|
|
17
18
|
*/
|
|
18
19
|
export function renderDonut(config) {
|
|
19
|
-
const { title, subtitle, data, legend, center, animate, format, id, showPercentages, downloadData, downloadDataUrl } = config;
|
|
20
|
+
const { title, subtitle, data, legend, center, animate, format, id, showPercentages, downloadData, downloadDataUrl, _columns } = config;
|
|
20
21
|
|
|
21
22
|
if (!data || data.length === 0) {
|
|
22
23
|
return `<!-- Donut chart: no data provided -->`;
|
|
@@ -24,10 +25,10 @@ export function renderDonut(config) {
|
|
|
24
25
|
|
|
25
26
|
const animateClass = animate ? ' chart-animate' : '';
|
|
26
27
|
|
|
27
|
-
// Get column keys
|
|
28
|
-
const labelKey = getLabelKey(data);
|
|
29
|
-
const valueKey = getValueKey(data);
|
|
30
|
-
const seriesKeys = getSeriesNames(data);
|
|
28
|
+
// Get column keys (use resolved columns if available)
|
|
29
|
+
const labelKey = _columns?.label ?? getLabelKey(data);
|
|
30
|
+
const valueKey = _columns?.values?.[0] ?? getValueKey(data);
|
|
31
|
+
const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
|
|
31
32
|
|
|
32
33
|
// Extract values - support both label/value format and series format
|
|
33
34
|
let segments = [];
|
|
@@ -102,25 +103,39 @@ export function renderDonut(config) {
|
|
|
102
103
|
|
|
103
104
|
html += `</div>`; // Close donut-body
|
|
104
105
|
|
|
106
|
+
// Build label lookup from: 1) yLabels (new schema), 2) legend array (deprecated), 3) segment labels
|
|
107
|
+
const labelMap = _columns?.yLabels || {};
|
|
108
|
+
const getSegmentLabel = (segment, index) => {
|
|
109
|
+
if (labelMap[segment.label]) return labelMap[segment.label];
|
|
110
|
+
if (Array.isArray(legend)) return legend[index] ?? segment.label;
|
|
111
|
+
return segment.label;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Get value format from resolved columns or global format
|
|
115
|
+
const valueFormat = _columns?.valueFormat ?? format;
|
|
116
|
+
|
|
105
117
|
// Legend with values (or percentages if showPercentages is true)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
displayValue
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
// Show if legend !== false (donut always shows legend by default)
|
|
119
|
+
const showLegend = config.legend !== false;
|
|
120
|
+
if (showLegend) {
|
|
121
|
+
html += `<ul class="chart-legend">`;
|
|
122
|
+
segments.forEach((segment, i) => {
|
|
123
|
+
const label = getSegmentLabel(segment, i);
|
|
124
|
+
let displayValue;
|
|
125
|
+
if (showPercentages) {
|
|
126
|
+
displayValue = ((segment.value / total) * 100).toFixed(1) + '%';
|
|
127
|
+
} else {
|
|
128
|
+
displayValue = formatNumber(segment.value, valueFormat) || segment.value;
|
|
129
|
+
}
|
|
130
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
131
|
+
const seriesClass = `chart-series-${slugify(segment.label)}`;
|
|
132
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">`;
|
|
133
|
+
html += `<span class="legend-label">${escapeHtml(label)}</span>`;
|
|
134
|
+
html += `<span class="legend-value">${escapeHtml(String(displayValue))}</span>`;
|
|
135
|
+
html += `</li>`;
|
|
136
|
+
});
|
|
137
|
+
html += `</ul>`;
|
|
138
|
+
}
|
|
124
139
|
|
|
125
140
|
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
126
141
|
html += `</figure>`;
|
package/src/renderers/dot.js
CHANGED
|
@@ -1,31 +1,44 @@
|
|
|
1
1
|
import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLink } from '../utils.js';
|
|
2
2
|
import { formatNumber } from '../formatters.js';
|
|
3
|
+
import { getAxisMax, getAxisMin, getAxisFormat, getRotateLabels } from '../config.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Render a categorical dot chart (columns with dots at different Y positions)
|
|
6
7
|
* Like atlas-wrapped's adoption chart - discrete X axis, continuous Y axis
|
|
7
|
-
* @param {Object} config - Chart configuration
|
|
8
|
+
* @param {Object} config - Chart configuration (normalized)
|
|
8
9
|
* @param {string} config.title - Chart title
|
|
9
10
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
10
11
|
* @param {Object[]} config.data - Chart data with label column and value columns
|
|
11
|
-
* @param {
|
|
12
|
-
* @param {number} [config.min] - Minimum Y value (defaults to min in data or 0)
|
|
12
|
+
* @param {Object} [config.y] - Y-axis configuration { max, min, format }
|
|
13
13
|
* @param {string[]} [config.legend] - Legend labels (defaults to series names)
|
|
14
14
|
* @param {boolean} [config.animate] - Enable animations
|
|
15
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
15
16
|
* @returns {string} - HTML string
|
|
16
17
|
*/
|
|
17
18
|
export function renderDot(config) {
|
|
18
|
-
const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id,
|
|
19
|
+
const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot', _columns } = config;
|
|
19
20
|
|
|
20
21
|
if (!data || data.length === 0) {
|
|
21
22
|
return `<!-- Dot chart: no data provided -->`;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
// Get label key
|
|
25
|
-
const labelKey = getLabelKey(data);
|
|
26
|
-
const seriesKeys = getSeriesNames(data);
|
|
27
|
-
|
|
25
|
+
// Get label key and series keys (use resolved columns if available)
|
|
26
|
+
const labelKey = _columns?.label ?? getLabelKey(data);
|
|
27
|
+
const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
|
|
28
|
+
|
|
29
|
+
// Build legend labels from: 1) yLabels (new), 2) legend array (deprecated), 3) column names
|
|
30
|
+
const yLabels = _columns?.yLabels || {};
|
|
31
|
+
const getSeriesLabel = (key, index) => {
|
|
32
|
+
if (yLabels[key]) return yLabels[key];
|
|
33
|
+
if (Array.isArray(legend)) return legend[index] ?? key;
|
|
34
|
+
return key;
|
|
35
|
+
};
|
|
36
|
+
|
|
28
37
|
const animateClass = animate ? ' chart-animate' : '';
|
|
38
|
+
const rotateLabels = getRotateLabels(config, config.id);
|
|
39
|
+
|
|
40
|
+
// Get Y-axis format
|
|
41
|
+
const yFormat = getAxisFormat(config, 'y');
|
|
29
42
|
|
|
30
43
|
// Calculate min and max values for Y scaling
|
|
31
44
|
const allValues = data.flatMap(row =>
|
|
@@ -36,8 +49,10 @@ export function renderDot(config) {
|
|
|
36
49
|
);
|
|
37
50
|
const dataMax = Math.max(...allValues);
|
|
38
51
|
const dataMin = Math.min(...allValues);
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
|
|
53
|
+
// Use normalized axis config, fall back to legacy top-level max/min
|
|
54
|
+
const maxValue = getAxisMax(config, 'y') ?? max ?? dataMax;
|
|
55
|
+
const minValue = getAxisMin(config, 'y') ?? min ?? (dataMin < 0 ? dataMin : 0);
|
|
41
56
|
const range = maxValue - minValue;
|
|
42
57
|
const hasNegativeY = minValue < 0;
|
|
43
58
|
|
|
@@ -63,10 +78,10 @@ export function renderDot(config) {
|
|
|
63
78
|
// Y-axis
|
|
64
79
|
const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
|
|
65
80
|
html += `<div class="chart-y-axis"${yAxisStyle}>`;
|
|
66
|
-
html += `<span class="axis-label">${formatNumber(maxValue,
|
|
81
|
+
html += `<span class="axis-label">${formatNumber(maxValue, yFormat) || maxValue}</span>`;
|
|
67
82
|
const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
|
|
68
|
-
html += `<span class="axis-label">${formatNumber(midLabelY,
|
|
69
|
-
html += `<span class="axis-label">${formatNumber(minValue,
|
|
83
|
+
html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
|
|
84
|
+
html += `<span class="axis-label">${formatNumber(minValue, yFormat) || minValue}</span>`;
|
|
70
85
|
html += `</div>`;
|
|
71
86
|
|
|
72
87
|
// Scroll wrapper for chart + labels
|
|
@@ -117,11 +132,11 @@ export function renderDot(config) {
|
|
|
117
132
|
const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
|
|
118
133
|
const colorClass = `chart-color-${i + 1}`;
|
|
119
134
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
120
|
-
const tooltipLabel =
|
|
135
|
+
const tooltipLabel = getSeriesLabel(key, i);
|
|
121
136
|
|
|
122
137
|
html += `<div class="dot ${colorClass} ${seriesClass}" `;
|
|
123
138
|
html += `style="--value: ${yPct.toFixed(2)}%" `;
|
|
124
|
-
html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value,
|
|
139
|
+
html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, yFormat) || value}"`;
|
|
125
140
|
html += `></div>`;
|
|
126
141
|
});
|
|
127
142
|
|
|
@@ -143,14 +158,15 @@ export function renderDot(config) {
|
|
|
143
158
|
html += `</div>`; // close chart-scroll
|
|
144
159
|
html += `</div>`; // close chart-body
|
|
145
160
|
|
|
146
|
-
// Legend
|
|
147
|
-
|
|
161
|
+
// Legend (show if legend !== false and we have series keys or legendTitle)
|
|
162
|
+
const showLegend = config.legend !== false && (seriesKeys.length > 0 || legendTitle);
|
|
163
|
+
if (showLegend) {
|
|
148
164
|
if (legendTitle) {
|
|
149
165
|
html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
|
|
150
166
|
}
|
|
151
167
|
html += `<ul class="chart-legend">`;
|
|
152
168
|
seriesKeys.forEach((key, i) => {
|
|
153
|
-
const label =
|
|
169
|
+
const label = getSeriesLabel(key, i);
|
|
154
170
|
const colorClass = `chart-color-${i + 1}`;
|
|
155
171
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
156
172
|
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
package/src/renderers/sankey.js
CHANGED
|
@@ -3,7 +3,7 @@ import { formatNumber } from '../formatters.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Render a Sankey diagram
|
|
6
|
-
* @param {Object} config - Chart configuration
|
|
6
|
+
* @param {Object} config - Chart configuration (normalized)
|
|
7
7
|
* @param {string} config.title - Chart title
|
|
8
8
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
9
|
* @param {Object[]} config.data - Chart data (source, target, value columns)
|
|
@@ -13,10 +13,11 @@ import { formatNumber } from '../formatters.js';
|
|
|
13
13
|
* @param {number} [config.nodePadding] - Vertical gap between nodes in pixels (default: 10)
|
|
14
14
|
* @param {boolean} [config.endLabelsOutside] - Position last level labels outside/right (default: false)
|
|
15
15
|
* @param {boolean} [config.proportional] - Force proportional node heights for data integrity (default: false)
|
|
16
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
16
17
|
* @returns {string} - HTML string
|
|
17
18
|
*/
|
|
18
19
|
export function renderSankey(config) {
|
|
19
|
-
const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, nodeWidth = 20, nodePadding = 10, endLabelsOutside = false, proportional = false } = config;
|
|
20
|
+
const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, nodeWidth = 20, nodePadding = 10, endLabelsOutside = false, proportional = false, _columns } = config;
|
|
20
21
|
|
|
21
22
|
if (!data || data.length === 0) {
|
|
22
23
|
return `<!-- Sankey chart: no data provided -->`;
|
|
@@ -24,11 +25,14 @@ export function renderSankey(config) {
|
|
|
24
25
|
|
|
25
26
|
const animateClass = animate ? ' chart-animate' : '';
|
|
26
27
|
|
|
27
|
-
// Get column keys
|
|
28
|
+
// Get column keys (use resolved columns if available)
|
|
28
29
|
const keys = Object.keys(data[0]);
|
|
29
|
-
const sourceKey = keys[0]; // First column: source
|
|
30
|
-
const targetKey = keys[1]; // Second column: target
|
|
31
|
-
const valueKey = keys[2];
|
|
30
|
+
const sourceKey = _columns?.source ?? keys[0]; // First column: source
|
|
31
|
+
const targetKey = _columns?.target ?? keys[1]; // Second column: target
|
|
32
|
+
const valueKey = _columns?.value ?? keys[2]; // Third column: value
|
|
33
|
+
|
|
34
|
+
// Get value format from resolved columns (new schema) or global format
|
|
35
|
+
const valueFormat = _columns?.valueFormat ?? format;
|
|
32
36
|
|
|
33
37
|
// Parse edges and build node set
|
|
34
38
|
const edges = [];
|
|
@@ -445,7 +449,7 @@ export function renderSankey(config) {
|
|
|
445
449
|
flows.forEach((flow, i) => {
|
|
446
450
|
const sourceColor = nodeColors.get(flow.source);
|
|
447
451
|
const targetColor = nodeColors.get(flow.target);
|
|
448
|
-
const tooltipText = `${flow.source} → ${flow.target}: ${formatNumber(flow.value,
|
|
452
|
+
const tooltipText = `${flow.source} → ${flow.target}: ${formatNumber(flow.value, valueFormat) || flow.value}`;
|
|
449
453
|
|
|
450
454
|
// Flow spans from after source node column to target node column
|
|
451
455
|
const colStart = flow.fromLevel * 2 + 2;
|
|
@@ -493,7 +497,7 @@ export function renderSankey(config) {
|
|
|
493
497
|
const colorClass = `chart-color-${colorIndex}`;
|
|
494
498
|
const seriesClass = `chart-series-${slugify(node)}`;
|
|
495
499
|
const throughput = nodeThroughput.get(node);
|
|
496
|
-
const tooltipText = `${node}: ${formatNumber(throughput,
|
|
500
|
+
const tooltipText = `${node}: ${formatNumber(throughput, valueFormat) || throughput}`;
|
|
497
501
|
|
|
498
502
|
html += `<div class="chart-sankey-node ${colorClass} ${seriesClass}" `;
|
|
499
503
|
html += `style="--top: ${pos.top.toFixed(2)}%; --height: ${pos.height.toFixed(2)}%" `;
|
|
@@ -514,8 +518,8 @@ export function renderSankey(config) {
|
|
|
514
518
|
const seriesClass = `chart-series-${slugify(node)}`;
|
|
515
519
|
const throughput = nodeThroughput.get(node);
|
|
516
520
|
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(node)}`;
|
|
517
|
-
if (
|
|
518
|
-
html += ` <span class="legend-value">${formatNumber(throughput,
|
|
521
|
+
if (valueFormat) {
|
|
522
|
+
html += ` <span class="legend-value">${formatNumber(throughput, valueFormat) || throughput}</span>`;
|
|
519
523
|
}
|
|
520
524
|
html += `</li>`;
|
|
521
525
|
});
|
package/src/renderers/scatter.js
CHANGED
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
import { slugify, escapeHtml, renderDownloadLink } from '../utils.js';
|
|
2
2
|
import { formatNumber } from '../formatters.js';
|
|
3
|
+
import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat } from '../config.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Render a scatter plot (continuous X and Y axes)
|
|
6
|
-
* @param {Object} config - Chart configuration
|
|
7
|
+
* @param {Object} config - Chart configuration (normalized)
|
|
7
8
|
* @param {string} config.title - Chart title
|
|
8
9
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
10
|
* @param {Object[]} config.data - Chart data (label + named columns: x, y, size, series)
|
|
10
|
-
* @param {
|
|
11
|
-
* @param {
|
|
12
|
-
* @param {number} [config.minX] - Minimum X value (defaults to min in data or 0)
|
|
13
|
-
* @param {number} [config.minY] - Minimum Y value (defaults to min in data or 0)
|
|
11
|
+
* @param {Object} [config.x] - X-axis configuration { max, min, title, format }
|
|
12
|
+
* @param {Object} [config.y] - Y-axis configuration { max, min, title, format }
|
|
14
13
|
* @param {string[]} [config.legend] - Legend labels for series
|
|
15
14
|
* @param {string} [config.legendTitle] - Title for series legend
|
|
16
15
|
* @param {string} [config.sizeTitle] - Title for size legend (enables size legend display)
|
|
17
16
|
* @param {boolean} [config.animate] - Enable animations
|
|
18
|
-
* @param {
|
|
19
|
-
* @param {string} [config.titleY] - Y-axis title (defaults to column name)
|
|
17
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
20
18
|
* @returns {string} - HTML string
|
|
21
19
|
*/
|
|
22
20
|
export function renderScatter(config) {
|
|
23
|
-
const { title, subtitle, data,
|
|
21
|
+
const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, proportional, _columns } = config;
|
|
24
22
|
|
|
25
|
-
//
|
|
26
|
-
const fmtX =
|
|
27
|
-
const fmtY =
|
|
23
|
+
// Get axis-specific format configs (normalized config provides x.format/y.format)
|
|
24
|
+
const fmtX = getAxisFormat(config, 'x');
|
|
25
|
+
const fmtY = getAxisFormat(config, 'y');
|
|
28
26
|
|
|
29
27
|
if (!data || data.length === 0) {
|
|
30
28
|
return `<!-- Scatter chart: no data provided -->`;
|
|
@@ -32,26 +30,37 @@ export function renderScatter(config) {
|
|
|
32
30
|
|
|
33
31
|
const animateClass = animate ? ' chart-animate' : '';
|
|
34
32
|
|
|
35
|
-
//
|
|
33
|
+
// Use resolved columns if available, otherwise fall back to implicit detection
|
|
36
34
|
const keys = Object.keys(data[0]);
|
|
37
35
|
const findKey = name => keys.find(k => k.toLowerCase() === name) || null;
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
let labelKey, xKey, yKey, sizeKey, seriesKey;
|
|
38
|
+
|
|
39
|
+
if (_columns) {
|
|
40
|
+
// Use pre-resolved columns from config normalization
|
|
41
|
+
labelKey = _columns.label;
|
|
42
|
+
xKey = _columns.x;
|
|
43
|
+
yKey = _columns.y;
|
|
44
|
+
sizeKey = _columns.size;
|
|
45
|
+
seriesKey = _columns.series;
|
|
46
|
+
} else {
|
|
47
|
+
// Fallback: implicit detection (for backwards compatibility)
|
|
48
|
+
labelKey = keys[0];
|
|
49
|
+
const namedX = findKey('x');
|
|
50
|
+
const namedY = findKey('y');
|
|
51
|
+
xKey = (namedX && namedY) ? namedX : keys[1];
|
|
52
|
+
yKey = (namedX && namedY) ? namedY : keys[2];
|
|
53
|
+
sizeKey = findKey('size');
|
|
54
|
+
seriesKey = findKey('series');
|
|
55
|
+
}
|
|
47
56
|
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
const
|
|
57
|
+
// Get legend/size titles from resolved columns (new schema) or deprecated top-level
|
|
58
|
+
const legendTitle = _columns?.seriesTitle ?? config.legendTitle;
|
|
59
|
+
const sizeTitle = _columns?.sizeTitle ?? config.sizeTitle;
|
|
51
60
|
|
|
52
|
-
// Axis titles:
|
|
53
|
-
const xAxisTitle =
|
|
54
|
-
const yAxisTitle =
|
|
61
|
+
// Axis titles: use normalized config, fall back to column names
|
|
62
|
+
const xAxisTitle = getAxisTitle(config, 'x', xKey);
|
|
63
|
+
const yAxisTitle = getAxisTitle(config, 'y', yKey);
|
|
55
64
|
|
|
56
65
|
// Map data to dots
|
|
57
66
|
const dots = data.map(item => ({
|
|
@@ -78,7 +87,7 @@ export function renderScatter(config) {
|
|
|
78
87
|
});
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
// Calculate bounds
|
|
90
|
+
// Calculate bounds using normalized axis config
|
|
82
91
|
const xValues = dots.map(d => d.x);
|
|
83
92
|
const yValues = dots.map(d => d.y);
|
|
84
93
|
const dataMaxX = Math.max(...xValues);
|
|
@@ -86,10 +95,10 @@ export function renderScatter(config) {
|
|
|
86
95
|
const dataMaxY = Math.max(...yValues);
|
|
87
96
|
const dataMinY = Math.min(...yValues);
|
|
88
97
|
|
|
89
|
-
const calcMaxX =
|
|
90
|
-
const calcMaxY =
|
|
91
|
-
const calcMinX =
|
|
92
|
-
const calcMinY =
|
|
98
|
+
const calcMaxX = getAxisMax(config, 'x') ?? dataMaxX;
|
|
99
|
+
const calcMaxY = getAxisMax(config, 'y') ?? dataMaxY;
|
|
100
|
+
const calcMinX = getAxisMin(config, 'x') ?? (dataMinX < 0 ? dataMinX : 0);
|
|
101
|
+
const calcMinY = getAxisMin(config, 'y') ?? (dataMinY < 0 ? dataMinY : 0);
|
|
93
102
|
const rangeX = calcMaxX - calcMinX;
|
|
94
103
|
const rangeY = calcMaxY - calcMinY;
|
|
95
104
|
const dataAspectRatio = rangeY > 0 ? rangeX / rangeY : 1;
|
|
@@ -1,33 +1,47 @@
|
|
|
1
1
|
import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml, renderDownloadLink } from '../utils.js';
|
|
2
2
|
import { formatNumber } from '../formatters.js';
|
|
3
|
+
import { getAxisMax, getAxisFormat } from '../config.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Render a stacked bar chart (horizontal)
|
|
6
|
-
* @param {Object} config - Chart configuration
|
|
7
|
+
* @param {Object} config - Chart configuration (normalized)
|
|
7
8
|
* @param {string} config.title - Chart title
|
|
8
9
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
10
|
* @param {Object[]} config.data - Chart data
|
|
10
|
-
* @param {
|
|
11
|
+
* @param {Object} [config.x] - X-axis configuration { max, format } (bars extend along X)
|
|
11
12
|
* @param {string[]} [config.legend] - Legend labels (defaults to series names)
|
|
12
13
|
* @param {boolean} [config.animate] - Enable animations
|
|
14
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
13
15
|
* @returns {string} - HTML string
|
|
14
16
|
*/
|
|
15
17
|
export function renderStackedBar(config) {
|
|
16
|
-
const { title, subtitle, data, max, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl } = config;
|
|
18
|
+
const { title, subtitle, data, max, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, _columns } = config;
|
|
17
19
|
|
|
18
20
|
if (!data || data.length === 0) {
|
|
19
21
|
return `<!-- Stacked bar chart: no data provided -->`;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
// Get label key
|
|
23
|
-
const labelKey = getLabelKey(data);
|
|
24
|
-
const seriesKeys = getSeriesNames(data);
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Get label key and series keys (use resolved columns if available)
|
|
25
|
+
const labelKey = _columns?.label ?? getLabelKey(data);
|
|
26
|
+
const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
|
|
27
|
+
|
|
28
|
+
// Build legend labels from: 1) xLabels (new - stacked-bar uses x for values), 2) legend array (deprecated), 3) column names
|
|
29
|
+
const xLabels = _columns?.xLabels || {};
|
|
30
|
+
const getSeriesLabel = (key, index) => {
|
|
31
|
+
if (xLabels[key]) return xLabels[key];
|
|
32
|
+
if (Array.isArray(legend)) return legend[index] ?? key;
|
|
33
|
+
return key;
|
|
34
|
+
};
|
|
35
|
+
|
|
27
36
|
const animateClass = animate ? ' chart-animate' : '';
|
|
28
37
|
|
|
38
|
+
// Get X-axis format (bars extend along X)
|
|
39
|
+
const xFormat = getAxisFormat(config, 'x');
|
|
40
|
+
|
|
29
41
|
// Calculate max total across all rows if not provided
|
|
30
|
-
|
|
42
|
+
// Use normalized x.max, fall back to legacy top-level max
|
|
43
|
+
const configMax = getAxisMax(config, 'x') ?? max;
|
|
44
|
+
const calculatedMax = configMax ?? Math.max(...data.map(row => {
|
|
31
45
|
return seriesKeys.reduce((sum, key) => {
|
|
32
46
|
const val = row[key];
|
|
33
47
|
return sum + (typeof val === 'number' ? val : parseFloat(val) || 0);
|
|
@@ -45,14 +59,15 @@ export function renderStackedBar(config) {
|
|
|
45
59
|
html += `</figcaption>`;
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
// Legend
|
|
49
|
-
|
|
62
|
+
// Legend (show if legend !== false and we have series keys or legendTitle)
|
|
63
|
+
const showLegend = config.legend !== false && (seriesKeys.length > 0 || legendTitle);
|
|
64
|
+
if (showLegend) {
|
|
50
65
|
if (legendTitle) {
|
|
51
66
|
html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
|
|
52
67
|
}
|
|
53
68
|
html += `<ul class="chart-legend">`;
|
|
54
69
|
seriesKeys.forEach((key, i) => {
|
|
55
|
-
const label =
|
|
70
|
+
const label = getSeriesLabel(key, i);
|
|
56
71
|
const colorClass = `chart-color-${i + 1}`;
|
|
57
72
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
58
73
|
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
@@ -74,12 +89,11 @@ export function renderStackedBar(config) {
|
|
|
74
89
|
});
|
|
75
90
|
const total = values.reduce((sum, v) => sum + v, 0);
|
|
76
91
|
const percentages = calculatePercentages(values, calculatedMax);
|
|
77
|
-
const seriesLabels = legendLabels ?? seriesKeys;
|
|
78
92
|
|
|
79
93
|
html += `<div class="bar-row" style="--row-index: ${rowIndex}">`;
|
|
80
94
|
html += `<span class="bar-label">${escapeHtml(label)}</span>`;
|
|
81
95
|
html += `<div class="bar-track">`;
|
|
82
|
-
html += `<div class="bar-fills" title="${escapeHtml(label)}: ${formatNumber(total,
|
|
96
|
+
html += `<div class="bar-fills" title="${escapeHtml(label)}: ${formatNumber(total, xFormat) || total}">`;
|
|
83
97
|
|
|
84
98
|
seriesKeys.forEach((key, i) => {
|
|
85
99
|
const pct = percentages[i];
|
|
@@ -87,8 +101,8 @@ export function renderStackedBar(config) {
|
|
|
87
101
|
if (pct > 0) {
|
|
88
102
|
const colorClass = `chart-color-${i + 1}`;
|
|
89
103
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
90
|
-
const seriesLabel =
|
|
91
|
-
html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${formatNumber(value,
|
|
104
|
+
const seriesLabel = getSeriesLabel(key, i);
|
|
105
|
+
html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${formatNumber(value, xFormat) || value}"></div>`;
|
|
92
106
|
}
|
|
93
107
|
});
|
|
94
108
|
|
|
@@ -96,7 +110,7 @@ export function renderStackedBar(config) {
|
|
|
96
110
|
html += `</div>`;
|
|
97
111
|
|
|
98
112
|
// Show total value
|
|
99
|
-
html += `<span class="bar-value">${formatNumber(total,
|
|
113
|
+
html += `<span class="bar-value">${formatNumber(total, xFormat) || total}</span>`;
|
|
100
114
|
html += `</div>`;
|
|
101
115
|
});
|
|
102
116
|
|
|
@@ -1,31 +1,43 @@
|
|
|
1
1
|
import { slugify, getLabelKey, getSeriesNames, escapeHtml, renderDownloadLink } from '../utils.js';
|
|
2
2
|
import { formatNumber } from '../formatters.js';
|
|
3
|
+
import { getAxisMax, getAxisMin, getAxisFormat, getRotateLabels } from '../config.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Render a stacked column chart (vertical)
|
|
6
|
-
* @param {Object} config - Chart configuration
|
|
7
|
+
* @param {Object} config - Chart configuration (normalized)
|
|
7
8
|
* @param {string} config.title - Chart title
|
|
8
9
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
10
|
* @param {Object[]} config.data - Chart data
|
|
10
|
-
* @param {
|
|
11
|
-
* @param {number} [config.min] - Minimum value for Y-axis scaling (for negative values)
|
|
11
|
+
* @param {Object} [config.y] - Y-axis configuration { max, min, format }
|
|
12
12
|
* @param {string[]} [config.legend] - Legend labels (defaults to series names)
|
|
13
13
|
* @param {boolean} [config.animate] - Enable animations
|
|
14
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
14
15
|
* @returns {string} - HTML string
|
|
15
16
|
*/
|
|
16
17
|
export function renderStackedColumn(config) {
|
|
17
|
-
const { title, subtitle, data, max, min, legend, animate, format, id,
|
|
18
|
+
const { title, subtitle, data, max, min, legend, animate, format, id, downloadData, downloadDataUrl, _columns } = config;
|
|
18
19
|
|
|
19
20
|
if (!data || data.length === 0) {
|
|
20
21
|
return `<!-- Stacked column chart: no data provided -->`;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
// Get label key
|
|
24
|
-
const labelKey = getLabelKey(data);
|
|
25
|
-
const seriesKeys = getSeriesNames(data);
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// Get label key and series keys (use resolved columns if available)
|
|
25
|
+
const labelKey = _columns?.label ?? getLabelKey(data);
|
|
26
|
+
const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
|
|
27
|
+
|
|
28
|
+
// Build legend labels from: 1) yLabels (new), 2) legend array (deprecated), 3) column names
|
|
29
|
+
const yLabels = _columns?.yLabels || {};
|
|
30
|
+
const getSeriesLabel = (key, index) => {
|
|
31
|
+
if (yLabels[key]) return yLabels[key];
|
|
32
|
+
if (Array.isArray(legend)) return legend[index] ?? key;
|
|
33
|
+
return key;
|
|
34
|
+
};
|
|
35
|
+
|
|
28
36
|
const animateClass = animate ? ' chart-animate' : '';
|
|
37
|
+
const rotateLabels = getRotateLabels(config, config.id);
|
|
38
|
+
|
|
39
|
+
// Get Y-axis format
|
|
40
|
+
const yFormat = getAxisFormat(config, 'y');
|
|
29
41
|
|
|
30
42
|
// Calculate stacked totals for positive and negative values separately
|
|
31
43
|
// Positives stack up from zero, negatives stack down from zero
|
|
@@ -48,9 +60,12 @@ export function renderStackedColumn(config) {
|
|
|
48
60
|
minNegativeStack = Math.min(minNegativeStack, negativeSum);
|
|
49
61
|
});
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
63
|
+
// Use normalized axis config, fall back to legacy top-level max/min
|
|
64
|
+
const configMaxY = getAxisMax(config, 'y') ?? max;
|
|
65
|
+
const configMinY = getAxisMin(config, 'y') ?? min;
|
|
66
|
+
const hasNegativeY = minNegativeStack < 0 || configMinY < 0;
|
|
67
|
+
const maxValue = configMaxY ?? maxPositiveStack;
|
|
68
|
+
const minValue = configMinY ?? minNegativeStack;
|
|
54
69
|
const range = maxValue - minValue;
|
|
55
70
|
const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
|
|
56
71
|
|
|
@@ -72,11 +87,11 @@ export function renderStackedColumn(config) {
|
|
|
72
87
|
// Y-axis with --zero-position for label positioning
|
|
73
88
|
const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
|
|
74
89
|
html += `<div class="chart-y-axis"${yAxisStyle}>`;
|
|
75
|
-
html += `<span class="axis-label">${formatNumber(maxValue,
|
|
90
|
+
html += `<span class="axis-label">${formatNumber(maxValue, yFormat) || maxValue}</span>`;
|
|
76
91
|
const midLabelY = hasNegativeY ? 0 : Math.round(maxValue / 2);
|
|
77
|
-
html += `<span class="axis-label">${formatNumber(midLabelY,
|
|
92
|
+
html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
|
|
78
93
|
const minLabelY = hasNegativeY ? minValue : 0;
|
|
79
|
-
html += `<span class="axis-label">${formatNumber(minLabelY,
|
|
94
|
+
html += `<span class="axis-label">${formatNumber(minLabelY, yFormat) || minLabelY}</span>`;
|
|
80
95
|
html += `</div>`;
|
|
81
96
|
|
|
82
97
|
// Scroll wrapper for columns + labels
|
|
@@ -107,7 +122,7 @@ export function renderStackedColumn(config) {
|
|
|
107
122
|
const value = typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
108
123
|
const colorClass = `chart-color-${i + 1}`;
|
|
109
124
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
110
|
-
const seriesLabel =
|
|
125
|
+
const seriesLabel = getSeriesLabel(key, i);
|
|
111
126
|
const segmentHeight = range > 0 ? (Math.abs(value) / range) * 100 : 0;
|
|
112
127
|
|
|
113
128
|
if (value >= 0) {
|
|
@@ -115,7 +130,7 @@ export function renderStackedColumn(config) {
|
|
|
115
130
|
classes: `column-segment ${colorClass} ${seriesClass}`,
|
|
116
131
|
bottom: positiveBottom,
|
|
117
132
|
height: segmentHeight,
|
|
118
|
-
title: `${escapeHtml(seriesLabel)}: ${formatNumber(value,
|
|
133
|
+
title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, yFormat) || value}`,
|
|
119
134
|
isNegative: false
|
|
120
135
|
});
|
|
121
136
|
lastPositiveIdx = segments.length - 1;
|
|
@@ -126,7 +141,7 @@ export function renderStackedColumn(config) {
|
|
|
126
141
|
classes: `column-segment ${colorClass} ${seriesClass} is-negative`,
|
|
127
142
|
bottom: negativeTop,
|
|
128
143
|
height: segmentHeight,
|
|
129
|
-
title: `${escapeHtml(seriesLabel)}: ${formatNumber(value,
|
|
144
|
+
title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, yFormat) || value}`,
|
|
130
145
|
isNegative: true
|
|
131
146
|
});
|
|
132
147
|
lastNegativeIdx = segments.length - 1;
|
|
@@ -157,11 +172,11 @@ export function renderStackedColumn(config) {
|
|
|
157
172
|
segmentData.forEach((seg, idx) => {
|
|
158
173
|
const colorClass = `chart-color-${seg.i + 1}`;
|
|
159
174
|
const seriesClass = `chart-series-${slugify(seg.key)}`;
|
|
160
|
-
const seriesLabel =
|
|
175
|
+
const seriesLabel = getSeriesLabel(seg.key, seg.i);
|
|
161
176
|
const endClass = idx === lastIdx ? ' is-stack-end' : '';
|
|
162
177
|
html += `<div class="column-segment ${colorClass} ${seriesClass}${endClass}" `;
|
|
163
178
|
html += `style="--value: ${seg.pct.toFixed(2)}%" `;
|
|
164
|
-
html += `title="${escapeHtml(seriesLabel)}: ${formatNumber(seg.value,
|
|
179
|
+
html += `title="${escapeHtml(seriesLabel)}: ${formatNumber(seg.value, yFormat) || seg.value}"></div>`;
|
|
165
180
|
});
|
|
166
181
|
}
|
|
167
182
|
|
|
@@ -181,11 +196,12 @@ export function renderStackedColumn(config) {
|
|
|
181
196
|
html += `</div>`; // close chart-scroll
|
|
182
197
|
html += `</div>`; // close chart-body
|
|
183
198
|
|
|
184
|
-
// Legend
|
|
185
|
-
|
|
199
|
+
// Legend (show if legend !== false and we have series keys)
|
|
200
|
+
const showLegend = config.legend !== false && seriesKeys.length > 0;
|
|
201
|
+
if (showLegend) {
|
|
186
202
|
html += `<ul class="chart-legend">`;
|
|
187
203
|
seriesKeys.forEach((key, i) => {
|
|
188
|
-
const label =
|
|
204
|
+
const label = getSeriesLabel(key, i);
|
|
189
205
|
const colorClass = `chart-color-${i + 1}`;
|
|
190
206
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
191
207
|
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|