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 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.docs.seanlunsford.com/) for configuration options, styling, animations, and more.
42
+ See the [documentation](https://uncharted.seanlunsford.com/) for configuration options, styling, animations, and more.
@@ -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
- ...chartConfig,
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.6.2",
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
+ }
@@ -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 positionally
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
- const legendLabels = legend ?? segments.map(s => s.label);
107
- html += `<ul class="chart-legend">`;
108
- segments.forEach((segment, i) => {
109
- const label = legendLabels[i] ?? segment.label;
110
- let displayValue;
111
- if (showPercentages) {
112
- displayValue = ((segment.value / total) * 100).toFixed(1) + '%';
113
- } else {
114
- displayValue = formatNumber(segment.value, format) || segment.value;
115
- }
116
- const colorClass = `chart-color-${i + 1}`;
117
- const seriesClass = `chart-series-${slugify(segment.label)}`;
118
- html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">`;
119
- html += `<span class="legend-label">${escapeHtml(label)}</span>`;
120
- html += `<span class="legend-value">${escapeHtml(String(displayValue))}</span>`;
121
- html += `</li>`;
122
- });
123
- html += `</ul>`;
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>`;
@@ -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 {number} [config.max] - Maximum Y value (defaults to max in data)
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, rotateLabels, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot' } = config;
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 (first column) and series keys (remaining columns)
25
- const labelKey = getLabelKey(data);
26
- const seriesKeys = getSeriesNames(data);
27
- const legendLabels = legend ?? seriesKeys;
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
- const maxValue = max ?? dataMax;
40
- const minValue = min ?? (dataMin < 0 ? dataMin : 0);
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, format) || maxValue}</span>`;
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, format) || midLabelY}</span>`;
69
- html += `<span class="axis-label">${formatNumber(minValue, format) || minValue}</span>`;
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 = legendLabels[i] ?? key;
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, format) || 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
- if (seriesKeys.length > 0 || legendTitle) {
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 = legendLabels[i] ?? key;
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>`;
@@ -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 positionally
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]; // Third column: value
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, format) || 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, format) || 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 (format) {
518
- html += ` <span class="legend-value">${formatNumber(throughput, format) || throughput}</span>`;
521
+ if (valueFormat) {
522
+ html += ` <span class="legend-value">${formatNumber(throughput, valueFormat) || throughput}</span>`;
519
523
  }
520
524
  html += `</li>`;
521
525
  });
@@ -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 {number} [config.maxX] - Maximum X value (defaults to max in data)
11
- * @param {number} [config.maxY] - Maximum Y value (defaults to max in data)
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 {string} [config.titleX] - X-axis title (defaults to column name)
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, maxX, maxY, minX, minY, legend, legendTitle, sizeTitle, animate, format, titleX, titleY, id, downloadData, downloadDataUrl, proportional } = config;
21
+ const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, proportional, _columns } = config;
24
22
 
25
- // Handle nested X/Y format for scatter charts
26
- const fmtX = format?.x || format || {};
27
- const fmtY = format?.y || format || {};
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
- // Named column detection (case-insensitive), with positional fallback for x/y
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
- // First column is always label
40
- const labelKey = keys[0];
41
-
42
- // X and Y: named if both exist, otherwise positional (columns 2 and 3)
43
- const namedX = findKey('x');
44
- const namedY = findKey('y');
45
- const xKey = (namedX && namedY) ? namedX : keys[1];
46
- const yKey = (namedX && namedY) ? namedY : keys[2];
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
- // Size and series: named only (no positional fallback)
49
- const sizeKey = findKey('size');
50
- const seriesKey = findKey('series');
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: explicit config overrides column names
53
- const xAxisTitle = titleX ?? xKey;
54
- const yAxisTitle = titleY ?? yKey;
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 = maxX ?? dataMaxX;
90
- const calcMaxY = maxY ?? dataMaxY;
91
- const calcMinX = minX ?? (dataMinX < 0 ? dataMinX : 0);
92
- const calcMinY = minY ?? (dataMinY < 0 ? dataMinY : 0);
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 {number} [config.max] - Maximum value for percentage calculation
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 (first column) and series keys (remaining columns)
23
- const labelKey = getLabelKey(data);
24
- const seriesKeys = getSeriesNames(data);
25
- // Use legend for display labels, fall back to data keys
26
- const legendLabels = legend ?? seriesKeys;
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
- const calculatedMax = max ?? Math.max(...data.map(row => {
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
- if (seriesKeys.length > 0 || legendTitle) {
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 = legendLabels[i] ?? key;
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, format) || 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 = seriesLabels[i] ?? key;
91
- html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}"></div>`;
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, format) || total}</span>`;
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 {number} [config.max] - Maximum value for Y-axis scaling
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, rotateLabels, downloadData, downloadDataUrl } = config;
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 (first column) and series keys (remaining columns)
24
- const labelKey = getLabelKey(data);
25
- const seriesKeys = getSeriesNames(data);
26
- // Use legend for display labels, fall back to data keys
27
- const legendLabels = legend ?? seriesKeys;
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
- const hasNegativeY = minNegativeStack < 0 || min < 0;
52
- const maxValue = max ?? maxPositiveStack;
53
- const minValue = min ?? minNegativeStack;
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, format) || maxValue}</span>`;
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, format) || midLabelY}</span>`;
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, format) || minLabelY}</span>`;
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 = legendLabels[i] ?? key;
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, format) || 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, format) || 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 = legendLabels[seg.i] ?? seg.key;
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, format) || seg.value}"></div>`;
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
- if (seriesKeys.length > 0) {
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 = legendLabels[i] ?? key;
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>`;