eleventy-plugin-uncharted 1.0.0-beta.1 → 1.0.0-beta.2

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.
@@ -4,6 +4,7 @@ import { renderers } from './src/renderers/index.js';
4
4
  import { loadCSV } from './src/csv.js';
5
5
  import { normalizeConfig } from './src/config.js';
6
6
  import { resolveColumns } from './src/columns.js';
7
+ import { validateChartType, checkDeprecatedOptions } from './src/deprecation.js';
7
8
  import {
8
9
  normalizeImageOptions,
9
10
  queueChartForImage,
@@ -145,11 +146,21 @@ export default function(eleventyConfig, options = {}) {
145
146
  return `<!-- Chart "${chartId}" has no type specified -->`;
146
147
  }
147
148
 
149
+ // Check for deprecated chart type (ERROR - prevents rendering)
150
+ const typeError = validateChartType(chartType, chartId);
151
+ if (typeError) {
152
+ console.error(`[uncharted] ${typeError}`);
153
+ return `<!-- ${typeError} -->`;
154
+ }
155
+
148
156
  const renderer = renderers[chartType];
149
157
  if (!renderer) {
150
158
  return `<!-- Unknown chart type "${chartType}" for chart "${chartId}" -->`;
151
159
  }
152
160
 
161
+ // Check for deprecated config options (WARNING - chart renders but option ignored)
162
+ checkDeprecatedOptions(chartConfig, chartId);
163
+
153
164
  // Load data from CSV file or use inline data
154
165
  let data = chartConfig.data;
155
166
  if (chartConfig.file && !data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
5
5
  "main": "eleventy.config.js",
6
6
  "type": "module",
package/src/columns.js CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Column resolution for Uncharted
3
3
  * Handles explicit column mapping and implicit detection
4
- * Supports both new unified schema (x.column, y.columns) and deprecated columns key
5
4
  */
6
5
 
7
6
  import { parseAxisConfig } from './config.js';
@@ -9,13 +8,10 @@ import { parseAxisConfig } from './config.js';
9
8
  /**
10
9
  * Resolve column mappings for chart data
11
10
  *
12
- * New unified schema (takes precedence):
11
+ * New unified schema:
13
12
  * - x: { column: "month" } or x: "month"
14
13
  * - y: { columns: ["a", "b"] } or y: { columns: { a: "Label A" } }
15
14
  *
16
- * Deprecated schema (fallback):
17
- * - columns: { x: "month", y: ["a", "b"] }
18
- *
19
15
  * @param {Object} config - Chart configuration with optional columns mapping
20
16
  * @param {Object[]} data - Chart data array
21
17
  * @param {string} chartType - Chart type for context-aware defaults
@@ -42,7 +38,6 @@ export function resolveColumns(config, data, chartType) {
42
38
  }
43
39
 
44
40
  const keys = Object.keys(data[0]);
45
- const deprecatedColumns = config.columns || {};
46
41
 
47
42
  // Helper to find column by name (case-insensitive)
48
43
  const findKey = name => keys.find(k => k.toLowerCase() === name.toLowerCase()) || null;
@@ -100,40 +95,28 @@ export function resolveColumns(config, data, chartType) {
100
95
  // Chart-type specific resolution
101
96
  if (chartType === 'bubble') {
102
97
  // Bubble charts use x (categorical), y, series, size columns
103
- // Like scatter but with categorical X axis
104
- // New schema: x.column, y.column, series.column, size.column
105
- // Deprecated: columns.x, columns.y, columns.series, columns.size
98
+ // Schema: x.column, y.column, series.column, size.column
106
99
 
107
100
  // X column (categorical)
108
101
  if (xConfig?.columns?.length) {
109
102
  resolved.x = validateColumn('x.column', xConfig.columns[0]);
110
- } else if (deprecatedColumns.x) {
111
- resolved.x = validateColumn('columns.x', deprecatedColumns.x);
112
103
  }
113
104
 
114
105
  // Y column
115
106
  if (yConfig?.columns?.length) {
116
107
  resolved.y = validateColumn('y.column', yConfig.columns[0]);
117
- } else if (deprecatedColumns.y) {
118
- resolved.y = validateColumn('columns.y', deprecatedColumns.y);
119
108
  }
120
109
 
121
110
  // Series column (for coloring)
122
111
  if (seriesConfig?.columns?.length) {
123
112
  resolved.series = validateColumn('series.column', seriesConfig.columns[0]);
124
113
  resolved.seriesTitle = seriesConfig.title;
125
- } else if (deprecatedColumns.series) {
126
- resolved.series = validateColumn('columns.series', deprecatedColumns.series);
127
- resolved.seriesTitle = config.legendTitle; // deprecated
128
114
  }
129
115
 
130
116
  // Size column
131
117
  if (sizeConfig?.columns?.length) {
132
118
  resolved.size = validateColumn('size.column', sizeConfig.columns[0]);
133
119
  resolved.sizeTitle = sizeConfig.title;
134
- } else if (deprecatedColumns.size) {
135
- resolved.size = validateColumn('columns.size', deprecatedColumns.size);
136
- resolved.sizeTitle = config.sizeTitle; // deprecated
137
120
  }
138
121
 
139
122
  // Implicit detection for bubble if not explicitly specified
@@ -168,28 +151,21 @@ export function resolveColumns(config, data, chartType) {
168
151
 
169
152
  } else if (chartType === 'scatter') {
170
153
  // Scatter charts use x, y, label, series, size columns
171
- // New schema: x.column, y.column, label.column, series.column, size.column
172
- // Deprecated: columns.x, columns.y, columns.label, columns.series, columns.size
154
+ // Schema: x.column, y.column, label.column, series.column, size.column
173
155
 
174
156
  // X column
175
157
  if (xConfig?.columns?.length) {
176
158
  resolved.x = validateColumn('x.column', xConfig.columns[0]);
177
- } else if (deprecatedColumns.x) {
178
- resolved.x = validateColumn('columns.x', deprecatedColumns.x);
179
159
  }
180
160
 
181
161
  // Y column
182
162
  if (yConfig?.columns?.length) {
183
163
  resolved.y = validateColumn('y.column', yConfig.columns[0]);
184
- } else if (deprecatedColumns.y) {
185
- resolved.y = validateColumn('columns.y', deprecatedColumns.y);
186
164
  }
187
165
 
188
166
  // Label column (point identifier)
189
167
  if (labelConfig?.columns?.length) {
190
168
  resolved.label = validateColumn('label.column', labelConfig.columns[0]);
191
- } else if (deprecatedColumns.label) {
192
- resolved.label = validateColumn('columns.label', deprecatedColumns.label);
193
169
  } else {
194
170
  resolved.label = keys[0];
195
171
  }
@@ -198,18 +174,12 @@ export function resolveColumns(config, data, chartType) {
198
174
  if (seriesConfig?.columns?.length) {
199
175
  resolved.series = validateColumn('series.column', seriesConfig.columns[0]);
200
176
  resolved.seriesTitle = seriesConfig.title;
201
- } else if (deprecatedColumns.series) {
202
- resolved.series = validateColumn('columns.series', deprecatedColumns.series);
203
- resolved.seriesTitle = config.legendTitle; // deprecated
204
177
  }
205
178
 
206
179
  // Size column
207
180
  if (sizeConfig?.columns?.length) {
208
181
  resolved.size = validateColumn('size.column', sizeConfig.columns[0]);
209
182
  resolved.sizeTitle = sizeConfig.title;
210
- } else if (deprecatedColumns.size) {
211
- resolved.size = validateColumn('columns.size', deprecatedColumns.size);
212
- resolved.sizeTitle = config.sizeTitle; // deprecated
213
183
  }
214
184
 
215
185
  // Implicit detection for scatter if not explicitly specified
@@ -236,21 +206,16 @@ export function resolveColumns(config, data, chartType) {
236
206
 
237
207
  } else if (chartType === 'sankey') {
238
208
  // Sankey charts use source, target, value columns
239
- // New schema: source.column, target.column, value.column
240
- // Deprecated: columns.source, columns.target, columns.value
209
+ // Schema: source.column, target.column, value.column
241
210
 
242
211
  if (sourceConfig?.columns?.length) {
243
212
  resolved.source = validateColumn('source.column', sourceConfig.columns[0]);
244
- } else if (deprecatedColumns.source) {
245
- resolved.source = validateColumn('columns.source', deprecatedColumns.source);
246
213
  } else {
247
214
  resolved.source = keys[0];
248
215
  }
249
216
 
250
217
  if (targetConfig?.columns?.length) {
251
218
  resolved.target = validateColumn('target.column', targetConfig.columns[0]);
252
- } else if (deprecatedColumns.target) {
253
- resolved.target = validateColumn('columns.target', deprecatedColumns.target);
254
219
  } else {
255
220
  resolved.target = keys[1];
256
221
  }
@@ -258,22 +223,17 @@ export function resolveColumns(config, data, chartType) {
258
223
  if (valueConfig?.columns?.length) {
259
224
  resolved.value = validateColumn('value.column', valueConfig.columns[0]);
260
225
  resolved.valueFormat = valueConfig.format;
261
- } else if (deprecatedColumns.value) {
262
- resolved.value = validateColumn('columns.value', deprecatedColumns.value);
263
226
  } else {
264
227
  resolved.value = keys[2];
265
228
  }
266
229
 
267
230
  } else if (chartType === 'donut') {
268
231
  // Donut charts use label, value columns
269
- // New schema: label.column, value.column
270
- // Deprecated: columns.label, columns.value (or implicit first/second columns)
232
+ // Schema: label.column, value.column
271
233
 
272
234
  if (labelConfig?.columns?.length) {
273
235
  resolved.label = validateColumn('label.column', labelConfig.columns[0]);
274
236
  resolved.yLabels = labelConfig.labels || {};
275
- } else if (deprecatedColumns.label) {
276
- resolved.label = validateColumn('columns.label', deprecatedColumns.label);
277
237
  } else {
278
238
  resolved.label = keys[0];
279
239
  }
@@ -284,9 +244,6 @@ export function resolveColumns(config, data, chartType) {
284
244
  resolved.values = [resolved.values];
285
245
  }
286
246
  resolved.valueFormat = valueConfig.format;
287
- } else if (deprecatedColumns.value) {
288
- const val = validateColumn('columns.value', deprecatedColumns.value);
289
- resolved.values = val ? [val] : [];
290
247
  } else {
291
248
  // Default: all columns except label
292
249
  resolved.values = keys.filter(k => k !== resolved.label);
@@ -294,14 +251,11 @@ export function resolveColumns(config, data, chartType) {
294
251
 
295
252
  } else if (chartType === 'stacked-bar') {
296
253
  // Stacked bar: y = categories (left side), x = value series (bars extend right)
297
- // New schema: y.column (categories), x.columns (values with labels)
298
- // Deprecated: columns.y (or label), columns.x (or y as values)
254
+ // Schema: y.column (categories), x.columns (values with labels)
299
255
 
300
256
  // Category column (on Y axis for stacked-bar)
301
257
  if (yConfig?.columns?.length) {
302
258
  resolved.label = validateColumn('y.column', yConfig.columns[0]);
303
- } else if (deprecatedColumns.label) {
304
- resolved.label = validateColumn('columns.label', deprecatedColumns.label);
305
259
  } else {
306
260
  resolved.label = keys[0];
307
261
  }
@@ -310,25 +264,18 @@ export function resolveColumns(config, data, chartType) {
310
264
  if (xConfig?.columns?.length) {
311
265
  resolved.values = validateColumn('x.columns', xConfig.columns) || [];
312
266
  resolved.xLabels = xConfig.labels || {};
313
- } else if (deprecatedColumns.y) {
314
- // Deprecated: columns.y was used for value columns in stacked-bar
315
- const explicitY = validateColumn('columns.y', deprecatedColumns.y);
316
- resolved.values = Array.isArray(explicitY) ? explicitY : (explicitY ? [explicitY] : []);
317
267
  } else {
318
268
  // Implicit: all columns except label are values
319
269
  resolved.values = keys.filter(k => k !== resolved.label);
320
270
  }
321
271
 
322
272
  } else {
323
- // Standard charts (dot, line, stacked-column): x = categories, y = multi-series values
324
- // New schema: x.column (categories), y.columns (values with labels)
325
- // Deprecated: columns.label (or implicit first), columns.y
273
+ // Standard charts (line, stacked-column): x = categories, y = multi-series values
274
+ // Schema: x.column (categories), y.columns (values with labels)
326
275
 
327
276
  // Label/category column (X axis)
328
277
  if (xConfig?.columns?.length) {
329
278
  resolved.label = validateColumn('x.column', xConfig.columns[0]);
330
- } else if (deprecatedColumns.label) {
331
- resolved.label = validateColumn('columns.label', deprecatedColumns.label);
332
279
  } else {
333
280
  resolved.label = keys[0];
334
281
  }
@@ -337,9 +284,6 @@ export function resolveColumns(config, data, chartType) {
337
284
  if (yConfig?.columns?.length) {
338
285
  resolved.values = validateColumn('y.columns', yConfig.columns) || [];
339
286
  resolved.yLabels = yConfig.labels || {};
340
- } else if (deprecatedColumns.y) {
341
- const explicitY = validateColumn('columns.y', deprecatedColumns.y);
342
- resolved.values = Array.isArray(explicitY) ? explicitY : (explicitY ? [explicitY] : []);
343
287
  } else {
344
288
  // Implicit: all columns except label are values
345
289
  resolved.values = keys.filter(k => k !== resolved.label);
package/src/config.js CHANGED
@@ -3,58 +3,22 @@
3
3
  * Transforms various config formats into a standardized structure
4
4
  */
5
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
6
  /**
36
7
  * Normalize chart configuration to standardized structure
37
8
  *
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)
9
+ * Axis properties:
10
+ * - x.max / y.max, x.min / y.min
11
+ * - x.title / y.title
12
+ * - x.format / y.format
47
13
  *
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)
14
+ * Column mapping:
15
+ * - x.column / x.columns / y.column / y.columns
51
16
  *
52
- * Legend precedence:
53
- * 1. y.columns: { key: "Label" } or x.columns (for stacked-bar)
54
- * 2. legend: ["Label1", "Label2"] (deprecated)
17
+ * Legend:
18
+ * - y.columns: { key: "Label" } or x.columns (for stacked-bar)
55
19
  *
56
20
  * @param {Object} config - Raw chart configuration
57
- * @param {string} [chartId] - Chart ID for deprecation warnings
21
+ * @param {string} [chartId] - Chart ID (unused, kept for API compatibility)
58
22
  * @returns {Object} - Normalized configuration
59
23
  */
60
24
  export function normalizeConfig(config, chartId = 'unknown') {
@@ -63,26 +27,10 @@ export function normalizeConfig(config, chartId = 'unknown') {
63
27
  const normalized = { ...config };
64
28
 
65
29
  // Build x axis config
66
- normalized.x = buildAxisConfig('x', config, chartId);
30
+ normalized.x = buildAxisConfig('x', config);
67
31
 
68
32
  // Build y axis config
69
- normalized.y = buildAxisConfig('y', config, chartId);
70
-
71
- // Warn for deprecated legend array (when used for labels, not boolean)
72
- if (Array.isArray(config.legend)) {
73
- warnDeprecation(chartId, 'legend (array)', 'y.columns: { key: "Label" }');
74
- }
75
-
76
- // Warn for deprecated scatter-specific keys
77
- if (config.legendTitle !== undefined) {
78
- warnDeprecation(chartId, 'legendTitle', 'series.title');
79
- }
80
- if (config.sizeTitle !== undefined) {
81
- warnDeprecation(chartId, 'sizeTitle', 'size.title');
82
- }
83
-
84
- // Clean up deprecated top-level keys (keep them for backwards compat but don't pass to renderers)
85
- // The renderers will use normalized.x and normalized.y instead
33
+ normalized.y = buildAxisConfig('y', config);
86
34
 
87
35
  return normalized;
88
36
  }
@@ -91,58 +39,22 @@ export function normalizeConfig(config, chartId = 'unknown') {
91
39
  * Build normalized axis configuration
92
40
  * @param {'x' | 'y'} axis - Axis name
93
41
  * @param {Object} config - Raw config
94
- * @param {string} chartId - Chart ID for warnings
95
42
  * @returns {Object} - Normalized axis config
96
43
  */
97
- function buildAxisConfig(axis, config, chartId) {
98
- const axisUpper = axis.toUpperCase();
44
+ function buildAxisConfig(axis, config) {
99
45
  const existingAxisConfig = config[axis] || {};
100
46
 
101
47
  // Start with existing axis config if present
102
48
  const axisConfig = { ...existingAxisConfig };
103
49
 
104
- // max: x.max > maxX > max
105
- if (axisConfig.max === undefined) {
106
- const deprecatedKey = `max${axisUpper}`;
107
- if (config[deprecatedKey] !== undefined) {
108
- warnDeprecation(chartId, deprecatedKey, `${axis}.max`);
109
- axisConfig.max = config[deprecatedKey];
110
- } else if (config.max !== undefined) {
111
- axisConfig.max = config.max;
112
- }
113
- }
114
-
115
- // min: x.min > minX > min
116
- if (axisConfig.min === undefined) {
117
- const deprecatedKey = `min${axisUpper}`;
118
- if (config[deprecatedKey] !== undefined) {
119
- warnDeprecation(chartId, deprecatedKey, `${axis}.min`);
120
- axisConfig.min = config[deprecatedKey];
121
- } else if (config.min !== undefined) {
122
- axisConfig.min = config.min;
123
- }
50
+ // max: x.max > max (global fallback)
51
+ if (axisConfig.max === undefined && config.max !== undefined) {
52
+ axisConfig.max = config.max;
124
53
  }
125
54
 
126
- // title: x.title > titleX
127
- if (axisConfig.title === undefined) {
128
- const deprecatedKey = `title${axisUpper}`;
129
- if (config[deprecatedKey] !== undefined) {
130
- warnDeprecation(chartId, deprecatedKey, `${axis}.title`);
131
- axisConfig.title = config[deprecatedKey];
132
- }
133
- }
134
-
135
- // format: x.format > format.x > format
136
- if (axisConfig.format === undefined) {
137
- const globalFormat = config.format || {};
138
- if (globalFormat[axis] !== undefined) {
139
- // format.x or format.y (deprecated nested format)
140
- warnDeprecation(chartId, `format.${axis}`, `${axis}.format`);
141
- axisConfig.format = globalFormat[axis];
142
- } else if (typeof globalFormat === 'object' && !globalFormat.x && !globalFormat.y) {
143
- // Global format object (no x/y nesting) - use as fallback
144
- axisConfig.format = globalFormat;
145
- }
55
+ // min: x.min > min (global fallback)
56
+ if (axisConfig.min === undefined && config.min !== undefined) {
57
+ axisConfig.min = config.min;
146
58
  }
147
59
 
148
60
  return axisConfig;
@@ -236,20 +148,10 @@ export function parseAxisConfig(axisConfig) {
236
148
  }
237
149
 
238
150
  /**
239
- * Get rotateLabels setting from axis config or deprecated top-level
151
+ * Get rotateLabels setting from axis config
240
152
  * @param {Object} config - Normalized config
241
- * @param {string} chartId - Chart ID for deprecation warnings
242
153
  * @returns {boolean} - Whether to rotate labels
243
154
  */
244
- export function getRotateLabels(config, chartId) {
245
- // New schema: x.rotateLabels
246
- if (config.x?.rotateLabels !== undefined) {
247
- return config.x.rotateLabels;
248
- }
249
- // Deprecated: top-level rotateLabels
250
- if (config.rotateLabels !== undefined) {
251
- warnDeprecation(chartId, 'rotateLabels', 'x.rotateLabels');
252
- return config.rotateLabels;
253
- }
254
- return false;
155
+ export function getRotateLabels(config) {
156
+ return config.x?.rotateLabels ?? false;
255
157
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Deprecation validation for Uncharted
3
+ * Validates config for deprecated usage and logs appropriate warnings/errors
4
+ */
5
+
6
+ // Track which deprecation warnings have been shown to avoid spam
7
+ const warnedConfigs = new Set();
8
+
9
+ /**
10
+ * Log a deprecation warning (once per config/key combination)
11
+ * @param {string} chartId - Chart identifier
12
+ * @param {string} oldKey - Deprecated key name
13
+ * @param {string} newKey - New key path
14
+ */
15
+ function warnDeprecation(chartId, oldKey, newKey) {
16
+ const key = `${chartId}:${oldKey}`;
17
+ if (warnedConfigs.has(key)) return;
18
+ warnedConfigs.add(key);
19
+ console.warn(`[uncharted] Chart "${chartId}": "${oldKey}" is deprecated, use "${newKey}" instead`);
20
+ }
21
+
22
+ /**
23
+ * Validate chart type and return error message if deprecated
24
+ * @param {string} type - Chart type
25
+ * @param {string} chartId - Chart identifier
26
+ * @returns {string|null} - Error message or null if valid
27
+ */
28
+ export function validateChartType(type, chartId) {
29
+ if (type === 'dot') {
30
+ return `Chart "${chartId}": type "dot" is no longer supported. Use type "line" with lines: false instead.`;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Check config for deprecated options and log warnings
37
+ * @param {Object} config - Chart configuration
38
+ * @param {string} chartId - Chart identifier
39
+ */
40
+ export function checkDeprecatedOptions(config, chartId) {
41
+ if (!config) return;
42
+
43
+ // Check for deprecated axis shorthand keys
44
+ if (config.maxX !== undefined) {
45
+ warnDeprecation(chartId, 'maxX', 'x.max');
46
+ }
47
+ if (config.minX !== undefined) {
48
+ warnDeprecation(chartId, 'minX', 'x.min');
49
+ }
50
+ if (config.maxY !== undefined) {
51
+ warnDeprecation(chartId, 'maxY', 'y.max');
52
+ }
53
+ if (config.minY !== undefined) {
54
+ warnDeprecation(chartId, 'minY', 'y.min');
55
+ }
56
+ if (config.titleX !== undefined) {
57
+ warnDeprecation(chartId, 'titleX', 'x.title');
58
+ }
59
+ if (config.titleY !== undefined) {
60
+ warnDeprecation(chartId, 'titleY', 'y.title');
61
+ }
62
+
63
+ // Check for deprecated top-level rotateLabels
64
+ if (config.rotateLabels !== undefined) {
65
+ warnDeprecation(chartId, 'rotateLabels', 'x.rotateLabels');
66
+ }
67
+
68
+ // Check for deprecated legend array (when used for labels, not boolean)
69
+ if (Array.isArray(config.legend)) {
70
+ warnDeprecation(chartId, 'legend (array)', 'y.columns: { key: "Label" }');
71
+ }
72
+
73
+ // Check for deprecated scatter-specific keys
74
+ if (config.legendTitle !== undefined) {
75
+ warnDeprecation(chartId, 'legendTitle', 'series.title');
76
+ }
77
+ if (config.sizeTitle !== undefined) {
78
+ warnDeprecation(chartId, 'sizeTitle', 'size.title');
79
+ }
80
+
81
+ // Check for deprecated format.x / format.y structure
82
+ if (config.format && typeof config.format === 'object') {
83
+ if (config.format.x !== undefined) {
84
+ warnDeprecation(chartId, 'format.x', 'x.format');
85
+ }
86
+ if (config.format.y !== undefined) {
87
+ warnDeprecation(chartId, 'format.y', 'y.format');
88
+ }
89
+ }
90
+
91
+ // Check for deprecated columns.* structure
92
+ if (config.columns && typeof config.columns === 'object') {
93
+ const deprecatedColumnKeys = ['x', 'y', 'label', 'series', 'size', 'source', 'target', 'value'];
94
+ for (const key of deprecatedColumnKeys) {
95
+ if (config.columns[key] !== undefined) {
96
+ const newPath = key === 'label' ? 'x.column or label.column' :
97
+ key === 'x' ? 'x.column' :
98
+ key === 'y' ? 'y.columns' :
99
+ `${key}.column`;
100
+ warnDeprecation(chartId, `columns.${key}`, newPath);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Clear warning cache (useful for testing)
108
+ */
109
+ export function clearWarningCache() {
110
+ warnedConfigs.clear();
111
+ }
@@ -27,7 +27,7 @@ export function renderBubble(config) {
27
27
  }
28
28
 
29
29
  const animateClass = animate ? ' chart-animate' : '';
30
- const rotateLabels = getRotateLabels(config, config.id);
30
+ const rotateLabels = getRotateLabels(config);
31
31
 
32
32
  // Use resolved columns if available, otherwise fall back to implicit detection
33
33
  const keys = Object.keys(data[0]);
@@ -49,9 +49,9 @@ export function renderBubble(config) {
49
49
  seriesKey = findKey('series');
50
50
  }
51
51
 
52
- // Get legend/size titles from resolved columns (new schema) or deprecated top-level
53
- const legendTitle = _columns?.seriesTitle ?? config.legendTitle;
54
- const sizeTitle = _columns?.sizeTitle ?? config.sizeTitle;
52
+ // Get legend/size titles from resolved columns (new schema only)
53
+ const legendTitle = _columns?.seriesTitle;
54
+ const sizeTitle = _columns?.sizeTitle;
55
55
 
56
56
  // Axis titles
57
57
  const xAxisTitle = getAxisTitle(config, 'x', '');
@@ -222,8 +222,9 @@ export function renderBubble(config) {
222
222
  html += `</div>`; // close chart-body
223
223
 
224
224
  // Legend (if multiple series or legendTitle specified)
225
- if (seriesList.length > 1 || legend || legendTitle) {
226
- const legendLabels = legend ?? seriesList;
225
+ // Note: legend array is deprecated; use series names directly
226
+ if (seriesList.length > 1 || legendTitle) {
227
+ const legendLabels = seriesList;
227
228
  if (legendTitle) {
228
229
  html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
229
230
  }
@@ -103,11 +103,10 @@ export function renderDonut(config) {
103
103
 
104
104
  html += `</div>`; // Close donut-body
105
105
 
106
- // Build label lookup from: 1) yLabels (new schema), 2) legend array (deprecated), 3) segment labels
106
+ // Build label lookup from yLabels (new schema) or segment labels
107
107
  const labelMap = _columns?.yLabels || {};
108
- const getSegmentLabel = (segment, index) => {
108
+ const getSegmentLabel = (segment) => {
109
109
  if (labelMap[segment.label]) return labelMap[segment.label];
110
- if (Array.isArray(legend)) return legend[index] ?? segment.label;
111
110
  return segment.label;
112
111
  };
113
112
 
@@ -120,7 +119,7 @@ export function renderDonut(config) {
120
119
  if (showLegend) {
121
120
  html += `<div class="chart-legend">`;
122
121
  segments.forEach((segment, i) => {
123
- const label = getSegmentLabel(segment, i);
122
+ const label = getSegmentLabel(segment);
124
123
  let displayValue;
125
124
  if (showPercentages) {
126
125
  displayValue = ((segment.value / total) * 100).toFixed(1) + '%';
@@ -1,7 +1,6 @@
1
1
  import { renderStackedBar } from './stacked-bar.js';
2
2
  import { renderStackedColumn } from './stacked-column.js';
3
3
  import { renderDonut } from './donut.js';
4
- import { renderDot } from './dot.js';
5
4
  import { renderScatter } from './scatter.js';
6
5
  import { renderSankey } from './sankey.js';
7
6
  import { renderLine } from './line.js';
@@ -12,7 +11,6 @@ export const renderers = {
12
11
  'stacked-bar': renderStackedBar,
13
12
  'stacked-column': renderStackedColumn,
14
13
  'donut': renderDonut,
15
- 'dot': renderDot,
16
14
  'scatter': renderScatter,
17
15
  'sankey': renderSankey,
18
16
  'line': renderLine,
@@ -20,4 +18,4 @@ export const renderers = {
20
18
  'bubble': renderBubble
21
19
  };
22
20
 
23
- export { renderStackedBar, renderStackedColumn, renderDonut, renderDot, renderScatter, renderSankey, renderLine, renderTimeseries, renderBubble };
21
+ export { renderStackedBar, renderStackedColumn, renderDonut, renderScatter, renderSankey, renderLine, renderTimeseries, renderBubble };
@@ -1,6 +1,218 @@
1
- import { renderDot } from './dot.js';
1
+ import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLink } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
3
+ import { getAxisMax, getAxisMin, getAxisFormat, getAxisTitle, getRotateLabels } from '../config.js';
2
4
 
5
+ /**
6
+ * Render a line chart (categorical X axis with connected data points)
7
+ * @param {Object} config - Chart configuration (normalized)
8
+ * @param {string} config.title - Chart title
9
+ * @param {string} [config.subtitle] - Chart subtitle
10
+ * @param {Object[]} config.data - Chart data with label column and value columns
11
+ * @param {Object} [config.y] - Y-axis configuration { max, min, format }
12
+ * @param {string[]} [config.legend] - Legend labels (defaults to series names)
13
+ * @param {boolean} [config.animate] - Enable animations
14
+ * @param {boolean} [config.lines] - Show connecting lines (default: true)
15
+ * @param {boolean} [config.dots] - Show dots at data points (default: true)
16
+ * @param {Object} [config._columns] - Resolved column mappings
17
+ * @returns {string} - HTML string
18
+ */
3
19
  export function renderLine(config) {
4
- const connectDots = config.lines !== false; // default true
5
- return renderDot({ ...config, connectDots, chartType: 'line' });
20
+ const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, icons, _columns } = config;
21
+
22
+ // Line-specific options
23
+ const showLines = config.lines !== false; // default true
24
+ const showDots = config.dots !== false; // default true
25
+
26
+ if (!data || data.length === 0) {
27
+ return `<!-- Line chart: no data provided -->`;
28
+ }
29
+
30
+ // Get label key and series keys (use resolved columns if available)
31
+ const labelKey = _columns?.label ?? getLabelKey(data);
32
+ const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
33
+
34
+ // Build legend labels from yLabels (new schema) or column names
35
+ const yLabels = _columns?.yLabels || {};
36
+ const getSeriesLabel = (key) => {
37
+ if (yLabels[key]) return yLabels[key];
38
+ return key;
39
+ };
40
+
41
+ // Helper to get icon for a series
42
+ const getSeriesIcon = (key) => {
43
+ if (!icons) return null;
44
+ if (typeof icons === 'string') return icons;
45
+ return icons[key] ?? null;
46
+ };
47
+
48
+ const animateClass = animate ? ' chart-animate' : '';
49
+ const rotateLabels = getRotateLabels(config);
50
+
51
+ // Get Y-axis format and axis titles
52
+ const yFormat = getAxisFormat(config, 'y');
53
+ const xAxisTitle = getAxisTitle(config, 'x', '');
54
+ const yAxisTitle = getAxisTitle(config, 'y', '');
55
+
56
+ // Calculate min and max values for Y scaling (exclude null values)
57
+ const allValues = data.flatMap(row =>
58
+ seriesKeys.map(key => row[key]).filter(val => val !== null && val !== undefined && val !== '')
59
+ ).map(val => typeof val === 'number' ? val : parseFloat(val)).filter(v => !isNaN(v));
60
+ const dataMax = Math.max(...allValues);
61
+ const dataMin = Math.min(...allValues);
62
+
63
+ // Use normalized axis config, fall back to legacy top-level max/min
64
+ const maxValue = getAxisMax(config, 'y') ?? max ?? dataMax;
65
+ const minValue = getAxisMin(config, 'y') ?? min ?? (dataMin < 0 ? dataMin : 0);
66
+ const range = maxValue - minValue;
67
+ const hasNegativeY = minValue < 0;
68
+
69
+ // Calculate zero position for axis line
70
+ const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
71
+
72
+ const negativeClass = hasNegativeY ? ' has-negative-y' : '';
73
+ const idClass = id ? ` chart-${id}` : '';
74
+ const rotateClass = rotateLabels ? ' rotate-labels' : '';
75
+ // Add no-dots class if dots are disabled (icons only affect legend, not dots)
76
+ const dotsClass = !showDots ? ' no-dots' : '';
77
+ let html = `<figure class="chart chart-line${animateClass}${negativeClass}${idClass}${rotateClass}${dotsClass}">`;
78
+
79
+ if (title) {
80
+ html += `<figcaption class="chart-title">${escapeHtml(title)}`;
81
+ if (subtitle) {
82
+ html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
83
+ }
84
+ html += `</figcaption>`;
85
+ }
86
+
87
+ html += `<div class="chart-body">`;
88
+
89
+ // Y-axis
90
+ const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
91
+ html += `<div class="chart-y-axis"${yAxisStyle}>`;
92
+ html += `<span class="axis-label">${formatNumber(maxValue, yFormat) || maxValue}</span>`;
93
+ const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
94
+ html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
95
+ html += `<span class="axis-label">${formatNumber(minValue, yFormat) || minValue}</span>`;
96
+ if (yAxisTitle) {
97
+ html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
98
+ }
99
+ html += `</div>`;
100
+
101
+ // Scroll wrapper for chart + labels
102
+ html += `<div class="chart-scroll">`;
103
+
104
+ // Calculate delay step to cap total stagger at 1s
105
+ const maxStagger = 1; // seconds
106
+ const defaultDelay = 0.08; // seconds
107
+ const delayStep = data.length > 1 ? Math.min(defaultDelay, maxStagger / (data.length - 1)) : 0;
108
+ const styleVars = [`--delay-step: ${delayStep.toFixed(3)}s`];
109
+ if (hasNegativeY) styleVars.push(`--zero-position: ${zeroPct.toFixed(2)}%`);
110
+ html += `<div class="dot-chart" style="${styleVars.join('; ')}">`;
111
+ html += `<div class="dot-field">`;
112
+
113
+ // CSS line segments connecting dots (rendered before dot-cols so they stack behind)
114
+ // Skip segments where either endpoint is null (gap in data)
115
+ if (showLines && data.length > 1) {
116
+ let segIndex = 0;
117
+ seriesKeys.forEach((key, i) => {
118
+ const colorClass = `chart-color-${i + 1}`;
119
+ const seriesClass = `chart-series-${slugify(key)}`;
120
+ for (let colIndex = 0; colIndex < data.length - 1; colIndex++) {
121
+ const val1 = data[colIndex][key];
122
+ const val2 = data[colIndex + 1][key];
123
+ // Skip segment if either endpoint is null/missing
124
+ if (val1 === null || val1 === undefined || val1 === '' ||
125
+ val2 === null || val2 === undefined || val2 === '') {
126
+ continue;
127
+ }
128
+ const v1 = typeof val1 === 'number' ? val1 : parseFloat(val1);
129
+ const v2 = typeof val2 === 'number' ? val2 : parseFloat(val2);
130
+ const y1 = range > 0 ? ((v1 - minValue) / range) * 100 : 0;
131
+ const y2 = range > 0 ? ((v2 - minValue) / range) * 100 : 0;
132
+ const x1 = ((colIndex + 0.5) / data.length) * 100;
133
+ const x2 = ((colIndex + 1.5) / data.length) * 100;
134
+ html += `<div class="chart-line-segment ${colorClass} ${seriesClass}" `;
135
+ html += `style="--x1: ${x1.toFixed(2)}; --y1: ${y1.toFixed(2)}; --x2: ${x2.toFixed(2)}; --y2: ${y2.toFixed(2)}; --seg-index: ${segIndex}">`;
136
+ html += `</div>`;
137
+ segIndex++;
138
+ }
139
+ });
140
+ }
141
+
142
+ // Always render dots for hover/tooltips (CSS handles visibility)
143
+ // Only show icons inside dots when dots are visible
144
+ data.forEach((row, colIndex) => {
145
+ const label = row[labelKey] ?? '';
146
+
147
+ html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
148
+
149
+ seriesKeys.forEach((key, i) => {
150
+ const val = row[key];
151
+ // Skip null/missing values - don't render a dot
152
+ if (val === null || val === undefined || val === '') return;
153
+
154
+ const value = typeof val === 'number' ? val : parseFloat(val) || 0;
155
+ const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
156
+ const colorClass = `chart-color-${i + 1}`;
157
+ const seriesClass = `chart-series-${slugify(key)}`;
158
+ const tooltipLabel = getSeriesLabel(key);
159
+ const icon = showDots ? getSeriesIcon(key) : null;
160
+ const iconClass = icon ? ' has-icon' : '';
161
+
162
+ html += `<div class="dot ${colorClass} ${seriesClass}${iconClass}" `;
163
+ html += `style="--value: ${yPct.toFixed(2)}%" `;
164
+ html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, yFormat) || value}"`;
165
+ html += `>`;
166
+ if (icon) {
167
+ html += `<i class="${escapeHtml(icon)}"></i>`;
168
+ }
169
+ html += `</div>`;
170
+ });
171
+
172
+ html += `</div>`;
173
+ });
174
+
175
+ html += `</div>`; // close dot-field
176
+ html += `</div>`; // close dot-chart
177
+
178
+ // X-axis labels
179
+ html += `<div class="dot-labels">`;
180
+ data.forEach(row => {
181
+ const label = row[labelKey] ?? '';
182
+ html += `<span class="dot-label">${escapeHtml(label)}</span>`;
183
+ });
184
+ if (xAxisTitle) {
185
+ html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
186
+ }
187
+ html += `</div>`;
188
+
189
+ html += `</div>`; // close chart-scroll
190
+ html += `</div>`; // close chart-body
191
+
192
+ // Legend (show if legend !== false and we have series keys or legendTitle)
193
+ const showLegend = config.legend !== false && (seriesKeys.length > 0 || legendTitle);
194
+ if (showLegend) {
195
+ if (legendTitle) {
196
+ html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
197
+ }
198
+ html += `<div class="chart-legend">`;
199
+ seriesKeys.forEach((key, i) => {
200
+ const label = getSeriesLabel(key);
201
+ const colorClass = `chart-color-${i + 1}`;
202
+ const seriesClass = `chart-series-${slugify(key)}`;
203
+ const icon = getSeriesIcon(key);
204
+ const iconClass = icon ? ' has-icon' : '';
205
+ html += `<span class="chart-legend-item ${colorClass} ${seriesClass}${iconClass}">`;
206
+ if (icon) {
207
+ html += `<i class="${escapeHtml(icon)}"></i>`;
208
+ }
209
+ html += `${escapeHtml(label)}</span>`;
210
+ });
211
+ html += `</div>`;
212
+ }
213
+
214
+ html += renderDownloadLink(downloadDataUrl, downloadData);
215
+ html += `</figure>`;
216
+
217
+ return html;
6
218
  }
@@ -54,9 +54,9 @@ export function renderScatter(config) {
54
54
  seriesKey = findKey('series');
55
55
  }
56
56
 
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;
57
+ // Get legend/size titles from resolved columns (new schema only)
58
+ const legendTitle = _columns?.seriesTitle;
59
+ const sizeTitle = _columns?.sizeTitle;
60
60
 
61
61
  // Axis titles: use normalized config, fall back to column names
62
62
  const xAxisTitle = getAxisTitle(config, 'x', xKey);
@@ -219,8 +219,9 @@ export function renderScatter(config) {
219
219
  html += `</div>`;
220
220
 
221
221
  // Legend (if multiple series or legendTitle specified)
222
- if (seriesList.length > 1 || legend || legendTitle) {
223
- const legendLabels = legend ?? seriesList;
222
+ // Note: legend array is deprecated; use series names directly
223
+ if (seriesList.length > 1 || legendTitle) {
224
+ const legendLabels = seriesList;
224
225
  if (legendTitle) {
225
226
  html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
226
227
  }
@@ -25,11 +25,10 @@ export function renderStackedBar(config) {
25
25
  const labelKey = _columns?.label ?? getLabelKey(data);
26
26
  const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
27
27
 
28
- // Build legend labels from: 1) xLabels (new - stacked-bar uses x for values), 2) legend array (deprecated), 3) column names
28
+ // Build legend labels from xLabels (new schema) or column names
29
29
  const xLabels = _columns?.xLabels || {};
30
- const getSeriesLabel = (key, index) => {
30
+ const getSeriesLabel = (key) => {
31
31
  if (xLabels[key]) return xLabels[key];
32
- if (Array.isArray(legend)) return legend[index] ?? key;
33
32
  return key;
34
33
  };
35
34
 
@@ -67,7 +66,7 @@ export function renderStackedBar(config) {
67
66
  }
68
67
  html += `<div class="chart-legend">`;
69
68
  seriesKeys.forEach((key, i) => {
70
- const label = getSeriesLabel(key, i);
69
+ const label = getSeriesLabel(key);
71
70
  const colorClass = `chart-color-${i + 1}`;
72
71
  const seriesClass = `chart-series-${slugify(key)}`;
73
72
  html += `<span class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</span>`;
@@ -101,7 +100,7 @@ export function renderStackedBar(config) {
101
100
  if (pct > 0) {
102
101
  const colorClass = `chart-color-${i + 1}`;
103
102
  const seriesClass = `chart-series-${slugify(key)}`;
104
- const seriesLabel = getSeriesLabel(key, i);
103
+ const seriesLabel = getSeriesLabel(key);
105
104
  html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${formatNumber(value, xFormat) || value}"></div>`;
106
105
  }
107
106
  });
@@ -25,16 +25,15 @@ export function renderStackedColumn(config) {
25
25
  const labelKey = _columns?.label ?? getLabelKey(data);
26
26
  const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
27
27
 
28
- // Build legend labels from: 1) yLabels (new), 2) legend array (deprecated), 3) column names
28
+ // Build legend labels from yLabels (new schema) or column names
29
29
  const yLabels = _columns?.yLabels || {};
30
- const getSeriesLabel = (key, index) => {
30
+ const getSeriesLabel = (key) => {
31
31
  if (yLabels[key]) return yLabels[key];
32
- if (Array.isArray(legend)) return legend[index] ?? key;
33
32
  return key;
34
33
  };
35
34
 
36
35
  const animateClass = animate ? ' chart-animate' : '';
37
- const rotateLabels = getRotateLabels(config, config.id);
36
+ const rotateLabels = getRotateLabels(config);
38
37
 
39
38
  // Get Y-axis format
40
39
  const yFormat = getAxisFormat(config, 'y');
@@ -122,7 +121,7 @@ export function renderStackedColumn(config) {
122
121
  const value = typeof val === 'number' ? val : parseFloat(val) || 0;
123
122
  const colorClass = `chart-color-${i + 1}`;
124
123
  const seriesClass = `chart-series-${slugify(key)}`;
125
- const seriesLabel = getSeriesLabel(key, i);
124
+ const seriesLabel = getSeriesLabel(key);
126
125
  const segmentHeight = range > 0 ? (Math.abs(value) / range) * 100 : 0;
127
126
 
128
127
  if (value >= 0) {
@@ -201,7 +200,7 @@ export function renderStackedColumn(config) {
201
200
  if (showLegend) {
202
201
  html += `<div class="chart-legend">`;
203
202
  seriesKeys.forEach((key, i) => {
204
- const label = getSeriesLabel(key, i);
203
+ const label = getSeriesLabel(key);
205
204
  const colorClass = `chart-color-${i + 1}`;
206
205
  const seriesClass = `chart-series-${slugify(key)}`;
207
206
  html += `<span class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</span>`;
@@ -262,14 +262,14 @@ export function renderTimeseries(config) {
262
262
  }
263
263
 
264
264
  // Build legend labels
265
+ // Build legend labels from yLabels (new schema) or column names
265
266
  const yLabels = _columns?.yLabels || {};
266
267
  const yColumnsLabels = config.y?.columns && typeof config.y.columns === 'object' && !Array.isArray(config.y.columns)
267
268
  ? config.y.columns
268
269
  : {};
269
- const getSeriesLabel = (key, index) => {
270
+ const getSeriesLabel = (key) => {
270
271
  if (yLabels[key]) return yLabels[key];
271
272
  if (yColumnsLabels[key]) return yColumnsLabels[key];
272
- if (Array.isArray(legend)) return legend[index] ?? key;
273
273
  return key;
274
274
  };
275
275
 
@@ -433,7 +433,7 @@ export function renderTimeseries(config) {
433
433
  const points = seriesData.get(key);
434
434
  const colorClass = `chart-color-${seriesIdx + 1}`;
435
435
  const seriesClass = `chart-series-${slugify(key)}`;
436
- const seriesLabel = getSeriesLabel(key, seriesIdx);
436
+ const seriesLabel = getSeriesLabel(key);
437
437
  const icon = showDots ? getSeriesIcon(key) : null;
438
438
  const iconClass = icon ? ' has-icon' : '';
439
439
 
@@ -484,7 +484,7 @@ export function renderTimeseries(config) {
484
484
  }
485
485
  html += `<div class="chart-legend">`;
486
486
  seriesKeys.forEach((key, i) => {
487
- const label = getSeriesLabel(key, i);
487
+ const label = getSeriesLabel(key);
488
488
  const colorClass = `chart-color-${i + 1}`;
489
489
  const seriesClass = `chart-series-${slugify(key)}`;
490
490
  const icon = getSeriesIcon(key);
@@ -1,222 +0,0 @@
1
- import { slugify, escapeHtml, getLabelKey, getSeriesNames, renderDownloadLink } from '../utils.js';
2
- import { formatNumber } from '../formatters.js';
3
- import { getAxisMax, getAxisMin, getAxisFormat, getAxisTitle, getRotateLabels } from '../config.js';
4
-
5
- /**
6
- * Render a categorical dot chart (columns with dots at different Y positions)
7
- * Like atlas-wrapped's adoption chart - discrete X axis, continuous Y axis
8
- * @param {Object} config - Chart configuration (normalized)
9
- * @param {string} config.title - Chart title
10
- * @param {string} [config.subtitle] - Chart subtitle
11
- * @param {Object[]} config.data - Chart data with label column and value columns
12
- * @param {Object} [config.y] - Y-axis configuration { max, min, format }
13
- * @param {string[]} [config.legend] - Legend labels (defaults to series names)
14
- * @param {boolean} [config.animate] - Enable animations
15
- * @param {Object} [config._columns] - Resolved column mappings
16
- * @returns {string} - HTML string
17
- */
18
- export function renderDot(config) {
19
- const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, connectDots, dots: showDots = true, icons, chartType = 'dot', _columns } = config;
20
-
21
- // Deprecation warning for dot chart type
22
- if (chartType === 'dot') {
23
- console.warn(
24
- '[uncharted] Chart type "dot" is deprecated. ' +
25
- 'Migrate to "line" with lines: false, or use "bubble" for sized dots.'
26
- );
27
- }
28
-
29
- if (!data || data.length === 0) {
30
- return `<!-- Dot chart: no data provided -->`;
31
- }
32
-
33
- // Get label key and series keys (use resolved columns if available)
34
- const labelKey = _columns?.label ?? getLabelKey(data);
35
- const seriesKeys = _columns?.values?.length > 0 ? _columns.values : getSeriesNames(data);
36
-
37
- // Build legend labels from: 1) yLabels (new), 2) legend array (deprecated), 3) column names
38
- const yLabels = _columns?.yLabels || {};
39
- const getSeriesLabel = (key, index) => {
40
- if (yLabels[key]) return yLabels[key];
41
- if (Array.isArray(legend)) return legend[index] ?? key;
42
- return key;
43
- };
44
-
45
- // Helper to get icon for a series
46
- const getSeriesIcon = (key) => {
47
- if (!icons) return null;
48
- if (typeof icons === 'string') return icons;
49
- return icons[key] ?? null;
50
- };
51
-
52
- const animateClass = animate ? ' chart-animate' : '';
53
- const rotateLabels = getRotateLabels(config, config.id);
54
-
55
- // Get Y-axis format and axis titles
56
- const yFormat = getAxisFormat(config, 'y');
57
- const xAxisTitle = getAxisTitle(config, 'x', '');
58
- const yAxisTitle = getAxisTitle(config, 'y', '');
59
-
60
- // Calculate min and max values for Y scaling (exclude null values)
61
- const allValues = data.flatMap(row =>
62
- seriesKeys.map(key => row[key]).filter(val => val !== null && val !== undefined && val !== '')
63
- ).map(val => typeof val === 'number' ? val : parseFloat(val)).filter(v => !isNaN(v));
64
- const dataMax = Math.max(...allValues);
65
- const dataMin = Math.min(...allValues);
66
-
67
- // Use normalized axis config, fall back to legacy top-level max/min
68
- const maxValue = getAxisMax(config, 'y') ?? max ?? dataMax;
69
- const minValue = getAxisMin(config, 'y') ?? min ?? (dataMin < 0 ? dataMin : 0);
70
- const range = maxValue - minValue;
71
- const hasNegativeY = minValue < 0;
72
-
73
- // Calculate zero position for axis line
74
- const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
75
-
76
- const negativeClass = hasNegativeY ? ' has-negative-y' : '';
77
- const idClass = id ? ` chart-${id}` : '';
78
- const rotateClass = rotateLabels ? ' rotate-labels' : '';
79
- // Add no-dots class if dots are disabled (icons only affect legend, not dots)
80
- const dotsClass = !showDots ? ' no-dots' : '';
81
- let html = `<figure class="chart chart-${chartType}${animateClass}${negativeClass}${idClass}${rotateClass}${dotsClass}">`;
82
-
83
- if (title) {
84
- html += `<figcaption class="chart-title">${escapeHtml(title)}`;
85
- if (subtitle) {
86
- html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
87
- }
88
- html += `</figcaption>`;
89
- }
90
-
91
- html += `<div class="chart-body">`;
92
-
93
- // Y-axis
94
- const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
95
- html += `<div class="chart-y-axis"${yAxisStyle}>`;
96
- html += `<span class="axis-label">${formatNumber(maxValue, yFormat) || maxValue}</span>`;
97
- const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
98
- html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
99
- html += `<span class="axis-label">${formatNumber(minValue, yFormat) || minValue}</span>`;
100
- if (yAxisTitle) {
101
- html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
102
- }
103
- html += `</div>`;
104
-
105
- // Scroll wrapper for chart + labels
106
- html += `<div class="chart-scroll">`;
107
-
108
- // Calculate delay step to cap total stagger at 1s
109
- const maxStagger = 1; // seconds
110
- const defaultDelay = 0.08; // seconds
111
- const delayStep = data.length > 1 ? Math.min(defaultDelay, maxStagger / (data.length - 1)) : 0;
112
- const styleVars = [`--delay-step: ${delayStep.toFixed(3)}s`];
113
- if (hasNegativeY) styleVars.push(`--zero-position: ${zeroPct.toFixed(2)}%`);
114
- html += `<div class="dot-chart" style="${styleVars.join('; ')}">`;
115
- html += `<div class="dot-field">`;
116
-
117
- // CSS line segments connecting dots (rendered before dot-cols so they stack behind)
118
- // Skip segments where either endpoint is null (gap in data)
119
- if (connectDots && data.length > 1) {
120
- let segIndex = 0;
121
- seriesKeys.forEach((key, i) => {
122
- const colorClass = `chart-color-${i + 1}`;
123
- const seriesClass = `chart-series-${slugify(key)}`;
124
- for (let colIndex = 0; colIndex < data.length - 1; colIndex++) {
125
- const val1 = data[colIndex][key];
126
- const val2 = data[colIndex + 1][key];
127
- // Skip segment if either endpoint is null/missing
128
- if (val1 === null || val1 === undefined || val1 === '' ||
129
- val2 === null || val2 === undefined || val2 === '') {
130
- continue;
131
- }
132
- const v1 = typeof val1 === 'number' ? val1 : parseFloat(val1);
133
- const v2 = typeof val2 === 'number' ? val2 : parseFloat(val2);
134
- const y1 = range > 0 ? ((v1 - minValue) / range) * 100 : 0;
135
- const y2 = range > 0 ? ((v2 - minValue) / range) * 100 : 0;
136
- const x1 = ((colIndex + 0.5) / data.length) * 100;
137
- const x2 = ((colIndex + 1.5) / data.length) * 100;
138
- html += `<div class="chart-line-segment ${colorClass} ${seriesClass}" `;
139
- html += `style="--x1: ${x1.toFixed(2)}; --y1: ${y1.toFixed(2)}; --x2: ${x2.toFixed(2)}; --y2: ${y2.toFixed(2)}; --seg-index: ${segIndex}">`;
140
- html += `</div>`;
141
- segIndex++;
142
- }
143
- });
144
- }
145
-
146
- // Always render dots for hover/tooltips (CSS handles visibility)
147
- // Only show icons inside dots when dots are visible
148
- data.forEach((row, colIndex) => {
149
- const label = row[labelKey] ?? '';
150
-
151
- html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
152
-
153
- seriesKeys.forEach((key, i) => {
154
- const val = row[key];
155
- // Skip null/missing values - don't render a dot
156
- if (val === null || val === undefined || val === '') return;
157
-
158
- const value = typeof val === 'number' ? val : parseFloat(val) || 0;
159
- const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
160
- const colorClass = `chart-color-${i + 1}`;
161
- const seriesClass = `chart-series-${slugify(key)}`;
162
- const tooltipLabel = getSeriesLabel(key, i);
163
- const icon = showDots ? getSeriesIcon(key) : null;
164
- const iconClass = icon ? ' has-icon' : '';
165
-
166
- html += `<div class="dot ${colorClass} ${seriesClass}${iconClass}" `;
167
- html += `style="--value: ${yPct.toFixed(2)}%" `;
168
- html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, yFormat) || value}"`;
169
- html += `>`;
170
- if (icon) {
171
- html += `<i class="${escapeHtml(icon)}"></i>`;
172
- }
173
- html += `</div>`;
174
- });
175
-
176
- html += `</div>`;
177
- });
178
-
179
- html += `</div>`; // close dot-field
180
- html += `</div>`; // close dot-chart
181
-
182
- // X-axis labels
183
- html += `<div class="dot-labels">`;
184
- data.forEach(row => {
185
- const label = row[labelKey] ?? '';
186
- html += `<span class="dot-label">${escapeHtml(label)}</span>`;
187
- });
188
- if (xAxisTitle) {
189
- html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
190
- }
191
- html += `</div>`;
192
-
193
- html += `</div>`; // close chart-scroll
194
- html += `</div>`; // close chart-body
195
-
196
- // Legend (show if legend !== false and we have series keys or legendTitle)
197
- const showLegend = config.legend !== false && (seriesKeys.length > 0 || legendTitle);
198
- if (showLegend) {
199
- if (legendTitle) {
200
- html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
201
- }
202
- html += `<div class="chart-legend">`;
203
- seriesKeys.forEach((key, i) => {
204
- const label = getSeriesLabel(key, i);
205
- const colorClass = `chart-color-${i + 1}`;
206
- const seriesClass = `chart-series-${slugify(key)}`;
207
- const icon = getSeriesIcon(key);
208
- const iconClass = icon ? ' has-icon' : '';
209
- html += `<span class="chart-legend-item ${colorClass} ${seriesClass}${iconClass}">`;
210
- if (icon) {
211
- html += `<i class="${escapeHtml(icon)}"></i>`;
212
- }
213
- html += `${escapeHtml(label)}</span>`;
214
- });
215
- html += `</div>`;
216
- }
217
-
218
- html += renderDownloadLink(downloadDataUrl, downloadData);
219
- html += `</figure>`;
220
-
221
- return html;
222
- }