eleventy-plugin-uncharted 0.7.5 → 0.9.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/css/uncharted.css +468 -42
- package/package.json +1 -1
- package/src/columns.js +69 -1
- package/src/csv.js +9 -3
- package/src/renderers/bubble.js +269 -0
- package/src/renderers/dot.js +72 -31
- package/src/renderers/index.js +6 -2
- package/src/renderers/line.js +2 -1
- package/src/renderers/scatter.js +23 -4
- package/src/renderers/timeseries.js +503 -0
package/src/columns.js
CHANGED
|
@@ -98,7 +98,75 @@ export function resolveColumns(config, data, chartType) {
|
|
|
98
98
|
};
|
|
99
99
|
|
|
100
100
|
// Chart-type specific resolution
|
|
101
|
-
if (chartType === '
|
|
101
|
+
if (chartType === 'bubble') {
|
|
102
|
+
// 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
|
|
106
|
+
|
|
107
|
+
// X column (categorical)
|
|
108
|
+
if (xConfig?.columns?.length) {
|
|
109
|
+
resolved.x = validateColumn('x.column', xConfig.columns[0]);
|
|
110
|
+
} else if (deprecatedColumns.x) {
|
|
111
|
+
resolved.x = validateColumn('columns.x', deprecatedColumns.x);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Y column
|
|
115
|
+
if (yConfig?.columns?.length) {
|
|
116
|
+
resolved.y = validateColumn('y.column', yConfig.columns[0]);
|
|
117
|
+
} else if (deprecatedColumns.y) {
|
|
118
|
+
resolved.y = validateColumn('columns.y', deprecatedColumns.y);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Series column (for coloring)
|
|
122
|
+
if (seriesConfig?.columns?.length) {
|
|
123
|
+
resolved.series = validateColumn('series.column', seriesConfig.columns[0]);
|
|
124
|
+
resolved.seriesTitle = seriesConfig.title;
|
|
125
|
+
} else if (deprecatedColumns.series) {
|
|
126
|
+
resolved.series = validateColumn('columns.series', deprecatedColumns.series);
|
|
127
|
+
resolved.seriesTitle = config.legendTitle; // deprecated
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Size column
|
|
131
|
+
if (sizeConfig?.columns?.length) {
|
|
132
|
+
resolved.size = validateColumn('size.column', sizeConfig.columns[0]);
|
|
133
|
+
resolved.sizeTitle = sizeConfig.title;
|
|
134
|
+
} else if (deprecatedColumns.size) {
|
|
135
|
+
resolved.size = validateColumn('columns.size', deprecatedColumns.size);
|
|
136
|
+
resolved.sizeTitle = config.sizeTitle; // deprecated
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Implicit detection for bubble if not explicitly specified
|
|
140
|
+
if (!resolved.x || !resolved.y) {
|
|
141
|
+
const namedX = findKey('x');
|
|
142
|
+
const namedY = findKey('y');
|
|
143
|
+
|
|
144
|
+
if (namedX && namedY) {
|
|
145
|
+
resolved.x = resolved.x ?? namedX;
|
|
146
|
+
resolved.y = resolved.y ?? namedY;
|
|
147
|
+
} else {
|
|
148
|
+
resolved.x = resolved.x ?? keys[0];
|
|
149
|
+
resolved.y = resolved.y ?? keys[1];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Implicit series/size detection
|
|
154
|
+
if (!resolved.series) {
|
|
155
|
+
resolved.series = findKey('series');
|
|
156
|
+
// Capture series title even with implicit detection
|
|
157
|
+
if (resolved.series && seriesConfig?.title) {
|
|
158
|
+
resolved.seriesTitle = seriesConfig.title;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!resolved.size) {
|
|
162
|
+
resolved.size = findKey('size') ?? keys[2]; // default to third column
|
|
163
|
+
}
|
|
164
|
+
// Capture size title even with implicit detection
|
|
165
|
+
if (resolved.size && !resolved.sizeTitle && sizeConfig?.title) {
|
|
166
|
+
resolved.sizeTitle = sizeConfig.title;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
} else if (chartType === 'scatter') {
|
|
102
170
|
// Scatter charts use x, y, label, series, size columns
|
|
103
171
|
// New schema: x.column, y.column, label.column, series.column, size.column
|
|
104
172
|
// Deprecated: columns.x, columns.y, columns.label, columns.series, columns.size
|
package/src/csv.js
CHANGED
|
@@ -68,9 +68,15 @@ export function parseCSV(content) {
|
|
|
68
68
|
|
|
69
69
|
headers.forEach((header, index) => {
|
|
70
70
|
const value = values[index] ?? '';
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// Empty cells become null (missing data)
|
|
72
|
+
if (value === '') {
|
|
73
|
+
row[header] = null;
|
|
74
|
+
} else {
|
|
75
|
+
// Only parse as number if the entire value is numeric
|
|
76
|
+
// This prevents dates like "2024-06-01" from becoming 2024
|
|
77
|
+
const isNumeric = /^-?\d+(\.\d+)?$/.test(value);
|
|
78
|
+
row[header] = isNumeric ? parseFloat(value) : value;
|
|
79
|
+
}
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
rows.push(row);
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { slugify, escapeHtml, renderDownloadLink } from '../utils.js';
|
|
2
|
+
import { formatNumber } from '../formatters.js';
|
|
3
|
+
import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat, getRotateLabels } from '../config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render a bubble chart (categorical X axis, continuous Y, variable dot sizes)
|
|
7
|
+
* Like scatter but with categorical X axis and always variable-sized dots
|
|
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 (x, y, size, optional series columns)
|
|
12
|
+
* @param {Object} [config.y] - Y-axis configuration { max, min, title, format }
|
|
13
|
+
* @param {Object} [config.size] - Size configuration { title }
|
|
14
|
+
* @param {string[]} [config.legend] - Legend labels for series
|
|
15
|
+
* @param {boolean} [config.animate] - Enable animations
|
|
16
|
+
* @param {Object} [config._columns] - Resolved column mappings
|
|
17
|
+
* @returns {string} - HTML string
|
|
18
|
+
*/
|
|
19
|
+
export function renderBubble(config) {
|
|
20
|
+
const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, icons, _columns } = config;
|
|
21
|
+
|
|
22
|
+
// Get axis-specific format configs
|
|
23
|
+
const fmtY = getAxisFormat(config, 'y');
|
|
24
|
+
|
|
25
|
+
if (!data || data.length === 0) {
|
|
26
|
+
return `<!-- Bubble chart: no data provided -->`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const animateClass = animate ? ' chart-animate' : '';
|
|
30
|
+
const rotateLabels = getRotateLabels(config, config.id);
|
|
31
|
+
|
|
32
|
+
// Use resolved columns if available, otherwise fall back to implicit detection
|
|
33
|
+
const keys = Object.keys(data[0]);
|
|
34
|
+
const findKey = name => keys.find(k => k.toLowerCase() === name) || null;
|
|
35
|
+
|
|
36
|
+
let xKey, yKey, sizeKey, seriesKey;
|
|
37
|
+
|
|
38
|
+
if (_columns) {
|
|
39
|
+
xKey = _columns.x;
|
|
40
|
+
yKey = _columns.y;
|
|
41
|
+
sizeKey = _columns.size;
|
|
42
|
+
seriesKey = _columns.series;
|
|
43
|
+
} else {
|
|
44
|
+
const namedX = findKey('x');
|
|
45
|
+
const namedY = findKey('y');
|
|
46
|
+
xKey = namedX || keys[0];
|
|
47
|
+
yKey = namedY || keys[1];
|
|
48
|
+
sizeKey = findKey('size') || keys[2];
|
|
49
|
+
seriesKey = findKey('series');
|
|
50
|
+
}
|
|
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;
|
|
55
|
+
|
|
56
|
+
// Axis titles
|
|
57
|
+
const xAxisTitle = getAxisTitle(config, 'x', '');
|
|
58
|
+
const yAxisTitle = getAxisTitle(config, 'y', '');
|
|
59
|
+
|
|
60
|
+
// Map data to dots
|
|
61
|
+
const dots = data.map(item => ({
|
|
62
|
+
x: item[xKey] ?? '',
|
|
63
|
+
y: typeof item[yKey] === 'number' ? item[yKey] : parseFloat(item[yKey]) || 0,
|
|
64
|
+
rawSize: sizeKey ? (typeof item[sizeKey] === 'number' ? item[sizeKey] : parseFloat(item[sizeKey]) || 0) : null,
|
|
65
|
+
series: seriesKey ? (item[seriesKey] ?? 'default') : 'default'
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Get unique X categories (maintain data order)
|
|
69
|
+
const seenCategories = new Set();
|
|
70
|
+
const categories = [];
|
|
71
|
+
dots.forEach(d => {
|
|
72
|
+
if (!seenCategories.has(d.x)) {
|
|
73
|
+
seenCategories.add(d.x);
|
|
74
|
+
categories.push(d.x);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Size normalization: non-positive values get minimum size (scale 0)
|
|
79
|
+
if (sizeKey) {
|
|
80
|
+
const sizeValues = dots.map(d => d.rawSize).filter(v => v > 0);
|
|
81
|
+
const minSizeVal = sizeValues.length ? Math.min(...sizeValues) : 1;
|
|
82
|
+
const maxSizeVal = sizeValues.length ? Math.max(...sizeValues) : 1;
|
|
83
|
+
const sizeRange = maxSizeVal - minSizeVal;
|
|
84
|
+
|
|
85
|
+
dots.forEach(dot => {
|
|
86
|
+
if (dot.rawSize <= 0 || sizeRange === 0) {
|
|
87
|
+
dot.sizeScale = 0;
|
|
88
|
+
} else {
|
|
89
|
+
dot.sizeScale = (dot.rawSize - minSizeVal) / sizeRange;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Calculate Y bounds
|
|
95
|
+
const yValues = dots.map(d => d.y);
|
|
96
|
+
const dataMaxY = Math.max(...yValues);
|
|
97
|
+
const dataMinY = Math.min(...yValues);
|
|
98
|
+
|
|
99
|
+
const calcMaxY = getAxisMax(config, 'y') ?? dataMaxY;
|
|
100
|
+
const calcMinY = getAxisMin(config, 'y') ?? (dataMinY < 0 ? dataMinY : 0);
|
|
101
|
+
const rangeY = calcMaxY - calcMinY;
|
|
102
|
+
|
|
103
|
+
const hasNegativeY = calcMinY < 0;
|
|
104
|
+
const zeroPctY = hasNegativeY ? ((0 - calcMinY) / rangeY) * 100 : 0;
|
|
105
|
+
|
|
106
|
+
// Get unique series
|
|
107
|
+
const seriesSet = new Set(dots.map(d => d.series));
|
|
108
|
+
const seriesList = Array.from(seriesSet);
|
|
109
|
+
const seriesIndex = new Map(seriesList.map((s, i) => [s, i]));
|
|
110
|
+
|
|
111
|
+
// Helper to get icon for a series
|
|
112
|
+
const getSeriesIcon = (seriesName) => {
|
|
113
|
+
if (!icons) return null;
|
|
114
|
+
if (typeof icons === 'string') return icons;
|
|
115
|
+
return icons[seriesName] ?? null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const negativeClass = hasNegativeY ? ' has-negative-y' : '';
|
|
119
|
+
const idClass = id ? ` chart-${id}` : '';
|
|
120
|
+
const rotateClass = rotateLabels ? ' rotate-labels' : '';
|
|
121
|
+
let html = `<figure class="chart chart-bubble${animateClass}${negativeClass}${idClass}${rotateClass}">`;
|
|
122
|
+
|
|
123
|
+
if (title) {
|
|
124
|
+
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
125
|
+
if (subtitle) {
|
|
126
|
+
html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
|
|
127
|
+
}
|
|
128
|
+
html += `</figcaption>`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
html += `<div class="chart-body">`;
|
|
132
|
+
|
|
133
|
+
// Y-axis
|
|
134
|
+
const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPctY.toFixed(2)}%"` : '';
|
|
135
|
+
html += `<div class="chart-y-axis"${yAxisStyle}>`;
|
|
136
|
+
html += `<span class="axis-label">${formatNumber(calcMaxY, fmtY) || calcMaxY}</span>`;
|
|
137
|
+
const midLabelY = hasNegativeY ? 0 : Math.round((calcMaxY + calcMinY) / 2);
|
|
138
|
+
html += `<span class="axis-label">${formatNumber(midLabelY, fmtY) || midLabelY}</span>`;
|
|
139
|
+
html += `<span class="axis-label">${formatNumber(calcMinY, fmtY) || calcMinY}</span>`;
|
|
140
|
+
if (yAxisTitle) {
|
|
141
|
+
html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
|
|
142
|
+
}
|
|
143
|
+
html += `</div>`;
|
|
144
|
+
|
|
145
|
+
// Scroll wrapper for chart + labels
|
|
146
|
+
html += `<div class="chart-scroll">`;
|
|
147
|
+
|
|
148
|
+
// Calculate delay step to cap total stagger at 1s
|
|
149
|
+
const maxStagger = 1;
|
|
150
|
+
const defaultDelay = 0.08;
|
|
151
|
+
const delayStep = dots.length > 1 ? Math.min(defaultDelay, maxStagger / (dots.length - 1)) : 0;
|
|
152
|
+
const styleVars = [`--delay-step: ${delayStep.toFixed(3)}s`];
|
|
153
|
+
if (hasNegativeY) styleVars.push(`--zero-position: ${zeroPctY.toFixed(2)}%`);
|
|
154
|
+
html += `<div class="dot-chart" style="${styleVars.join('; ')}">`;
|
|
155
|
+
html += `<div class="dot-field">`;
|
|
156
|
+
|
|
157
|
+
// Group dots by category
|
|
158
|
+
const dotsByCategory = new Map();
|
|
159
|
+
categories.forEach(cat => dotsByCategory.set(cat, []));
|
|
160
|
+
dots.forEach((dot, i) => {
|
|
161
|
+
dotsByCategory.get(dot.x).push({ ...dot, originalIndex: i });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const fmtSize = format?.size || {};
|
|
165
|
+
|
|
166
|
+
// Render dots by category column
|
|
167
|
+
categories.forEach((category, colIndex) => {
|
|
168
|
+
const categoryDots = dotsByCategory.get(category);
|
|
169
|
+
|
|
170
|
+
html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
|
|
171
|
+
|
|
172
|
+
categoryDots.forEach((dot) => {
|
|
173
|
+
const yPct = rangeY > 0 ? ((dot.y - calcMinY) / rangeY) * 100 : 0;
|
|
174
|
+
const colorIndex = seriesIndex.get(dot.series) + 1;
|
|
175
|
+
const colorClass = `chart-color-${colorIndex}`;
|
|
176
|
+
const seriesClass = `chart-series-${slugify(dot.series)}`;
|
|
177
|
+
const icon = getSeriesIcon(dot.series);
|
|
178
|
+
const iconClass = icon ? ' has-icon' : '';
|
|
179
|
+
|
|
180
|
+
// Build tooltip
|
|
181
|
+
let tooltipText = `${category}: ${formatNumber(dot.y, fmtY) || dot.y}`;
|
|
182
|
+
if (seriesKey && dot.series !== 'default') {
|
|
183
|
+
tooltipText = `${dot.series} - ${tooltipText}`;
|
|
184
|
+
}
|
|
185
|
+
if (sizeKey && dot.rawSize !== null) {
|
|
186
|
+
const fmtSizeVal = formatNumber(dot.rawSize, fmtSize) || dot.rawSize;
|
|
187
|
+
tooltipText += ` [${fmtSizeVal}]`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Build style string with size scale
|
|
191
|
+
let styleStr = `--value: ${yPct.toFixed(2)}%; --dot-index: ${dot.originalIndex}`;
|
|
192
|
+
if (sizeKey) {
|
|
193
|
+
styleStr += `; --size-scale: ${dot.sizeScale.toFixed(4)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
html += `<div class="dot ${colorClass} ${seriesClass}${iconClass}" `;
|
|
197
|
+
html += `style="${styleStr}" `;
|
|
198
|
+
html += `title="${escapeHtml(tooltipText)}"`;
|
|
199
|
+
html += `>`;
|
|
200
|
+
if (icon) {
|
|
201
|
+
html += `<i class="${escapeHtml(icon)}"></i>`;
|
|
202
|
+
}
|
|
203
|
+
html += `</div>`;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
html += `</div>`;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
html += `</div>`; // close dot-field
|
|
210
|
+
html += `</div>`; // close dot-chart
|
|
211
|
+
|
|
212
|
+
// X-axis labels
|
|
213
|
+
html += `<div class="dot-labels">`;
|
|
214
|
+
categories.forEach(category => {
|
|
215
|
+
html += `<span class="dot-label">${escapeHtml(category)}</span>`;
|
|
216
|
+
});
|
|
217
|
+
if (xAxisTitle) {
|
|
218
|
+
html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
|
|
219
|
+
}
|
|
220
|
+
html += `</div>`;
|
|
221
|
+
|
|
222
|
+
html += `</div>`; // close chart-scroll
|
|
223
|
+
html += `</div>`; // close chart-body
|
|
224
|
+
|
|
225
|
+
// Legend (if multiple series or legendTitle specified)
|
|
226
|
+
if (seriesList.length > 1 || legend || legendTitle) {
|
|
227
|
+
const legendLabels = legend ?? seriesList;
|
|
228
|
+
if (legendTitle) {
|
|
229
|
+
html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
|
|
230
|
+
}
|
|
231
|
+
html += `<div class="chart-legend">`;
|
|
232
|
+
seriesList.forEach((series, i) => {
|
|
233
|
+
const label = legendLabels[i] ?? series;
|
|
234
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
235
|
+
const seriesClass = `chart-series-${slugify(series)}`;
|
|
236
|
+
const icon = getSeriesIcon(series);
|
|
237
|
+
const iconClass = icon ? ' has-icon' : '';
|
|
238
|
+
html += `<span class="chart-legend-item ${colorClass} ${seriesClass}${iconClass}">`;
|
|
239
|
+
if (icon) {
|
|
240
|
+
html += `<i class="${escapeHtml(icon)}"></i>`;
|
|
241
|
+
}
|
|
242
|
+
html += `${escapeHtml(label)}</span>`;
|
|
243
|
+
});
|
|
244
|
+
html += `</div>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Size legend (when sizeTitle is specified and size column exists)
|
|
248
|
+
if (sizeTitle && sizeKey) {
|
|
249
|
+
const sizeValues = dots.map(d => d.rawSize).filter(v => v > 0);
|
|
250
|
+
const minSizeVal = sizeValues.length ? Math.min(...sizeValues) : 0;
|
|
251
|
+
const maxSizeVal = sizeValues.length ? Math.max(...sizeValues) : 0;
|
|
252
|
+
const fmtSizeLegend = format?.size || format || {};
|
|
253
|
+
const minFormatted = formatNumber(minSizeVal, fmtSizeLegend) || minSizeVal;
|
|
254
|
+
const maxFormatted = formatNumber(maxSizeVal, fmtSizeLegend) || maxSizeVal;
|
|
255
|
+
|
|
256
|
+
html += `<div class="chart-size-legend">`;
|
|
257
|
+
html += `<span class="chart-legend-title">${escapeHtml(sizeTitle)}</span>`;
|
|
258
|
+
html += `<div class="size-legend-items">`;
|
|
259
|
+
html += `<span class="size-legend-item"><span class="size-dot size-dot-min"></span><span class="size-value">${minFormatted}</span></span>`;
|
|
260
|
+
html += `<span class="size-legend-item"><span class="size-dot size-dot-max"></span><span class="size-value">${maxFormatted}</span></span>`;
|
|
261
|
+
html += `</div>`;
|
|
262
|
+
html += `</div>`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
266
|
+
html += `</figure>`;
|
|
267
|
+
|
|
268
|
+
return html;
|
|
269
|
+
}
|
package/src/renderers/dot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
+
import { getAxisMax, getAxisMin, getAxisFormat, getAxisTitle, getRotateLabels } from '../config.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Render a categorical dot chart (columns with dots at different Y positions)
|
|
@@ -16,7 +16,15 @@ import { getAxisMax, getAxisMin, getAxisFormat, getRotateLabels } from '../confi
|
|
|
16
16
|
* @returns {string} - HTML string
|
|
17
17
|
*/
|
|
18
18
|
export function renderDot(config) {
|
|
19
|
-
const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot', _columns } = 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 showLines: false, or use "bubble" for sized dots.'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
if (!data || data.length === 0) {
|
|
22
30
|
return `<!-- Dot chart: no data provided -->`;
|
|
@@ -34,19 +42,25 @@ export function renderDot(config) {
|
|
|
34
42
|
return key;
|
|
35
43
|
};
|
|
36
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
|
+
|
|
37
52
|
const animateClass = animate ? ' chart-animate' : '';
|
|
38
53
|
const rotateLabels = getRotateLabels(config, config.id);
|
|
39
54
|
|
|
40
|
-
// Get Y-axis format
|
|
55
|
+
// Get Y-axis format and axis titles
|
|
41
56
|
const yFormat = getAxisFormat(config, 'y');
|
|
57
|
+
const xAxisTitle = getAxisTitle(config, 'x', '');
|
|
58
|
+
const yAxisTitle = getAxisTitle(config, 'y', '');
|
|
42
59
|
|
|
43
|
-
// Calculate min and max values for Y scaling
|
|
60
|
+
// Calculate min and max values for Y scaling (exclude null values)
|
|
44
61
|
const allValues = data.flatMap(row =>
|
|
45
|
-
seriesKeys.map(key =>
|
|
46
|
-
|
|
47
|
-
return typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
48
|
-
})
|
|
49
|
-
);
|
|
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));
|
|
50
64
|
const dataMax = Math.max(...allValues);
|
|
51
65
|
const dataMin = Math.min(...allValues);
|
|
52
66
|
|
|
@@ -62,6 +76,7 @@ export function renderDot(config) {
|
|
|
62
76
|
const negativeClass = hasNegativeY ? ' has-negative-y' : '';
|
|
63
77
|
const idClass = id ? ` chart-${id}` : '';
|
|
64
78
|
const rotateClass = rotateLabels ? ' rotate-labels' : '';
|
|
79
|
+
// Add no-dots class if dots are disabled (icons only affect legend, not dots)
|
|
65
80
|
const dotsClass = !showDots ? ' no-dots' : '';
|
|
66
81
|
let html = `<figure class="chart chart-${chartType}${animateClass}${negativeClass}${idClass}${rotateClass}${dotsClass}">`;
|
|
67
82
|
|
|
@@ -82,6 +97,9 @@ export function renderDot(config) {
|
|
|
82
97
|
const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
|
|
83
98
|
html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
|
|
84
99
|
html += `<span class="axis-label">${formatNumber(minValue, yFormat) || minValue}</span>`;
|
|
100
|
+
if (yAxisTitle) {
|
|
101
|
+
html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
|
|
102
|
+
}
|
|
85
103
|
html += `</div>`;
|
|
86
104
|
|
|
87
105
|
// Scroll wrapper for chart + labels
|
|
@@ -97,6 +115,7 @@ export function renderDot(config) {
|
|
|
97
115
|
html += `<div class="dot-field">`;
|
|
98
116
|
|
|
99
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)
|
|
100
119
|
if (connectDots && data.length > 1) {
|
|
101
120
|
let segIndex = 0;
|
|
102
121
|
seriesKeys.forEach((key, i) => {
|
|
@@ -105,8 +124,13 @@ export function renderDot(config) {
|
|
|
105
124
|
for (let colIndex = 0; colIndex < data.length - 1; colIndex++) {
|
|
106
125
|
const val1 = data[colIndex][key];
|
|
107
126
|
const val2 = data[colIndex + 1][key];
|
|
108
|
-
|
|
109
|
-
|
|
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);
|
|
110
134
|
const y1 = range > 0 ? ((v1 - minValue) / range) * 100 : 0;
|
|
111
135
|
const y2 = range > 0 ? ((v2 - minValue) / range) * 100 : 0;
|
|
112
136
|
const x1 = ((colIndex + 0.5) / data.length) * 100;
|
|
@@ -119,30 +143,38 @@ export function renderDot(config) {
|
|
|
119
143
|
});
|
|
120
144
|
}
|
|
121
145
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
|
|
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] ?? '';
|
|
128
150
|
|
|
129
|
-
|
|
130
|
-
const val = row[key];
|
|
131
|
-
const value = typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
132
|
-
const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
|
|
133
|
-
const colorClass = `chart-color-${i + 1}`;
|
|
134
|
-
const seriesClass = `chart-series-${slugify(key)}`;
|
|
135
|
-
const tooltipLabel = getSeriesLabel(key, i);
|
|
151
|
+
html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
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;
|
|
142
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
|
+
}
|
|
143
173
|
html += `</div>`;
|
|
144
174
|
});
|
|
145
|
-
|
|
175
|
+
|
|
176
|
+
html += `</div>`;
|
|
177
|
+
});
|
|
146
178
|
|
|
147
179
|
html += `</div>`; // close dot-field
|
|
148
180
|
html += `</div>`; // close dot-chart
|
|
@@ -153,6 +185,9 @@ export function renderDot(config) {
|
|
|
153
185
|
const label = row[labelKey] ?? '';
|
|
154
186
|
html += `<span class="dot-label">${escapeHtml(label)}</span>`;
|
|
155
187
|
});
|
|
188
|
+
if (xAxisTitle) {
|
|
189
|
+
html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
|
|
190
|
+
}
|
|
156
191
|
html += `</div>`;
|
|
157
192
|
|
|
158
193
|
html += `</div>`; // close chart-scroll
|
|
@@ -169,7 +204,13 @@ export function renderDot(config) {
|
|
|
169
204
|
const label = getSeriesLabel(key, i);
|
|
170
205
|
const colorClass = `chart-color-${i + 1}`;
|
|
171
206
|
const seriesClass = `chart-series-${slugify(key)}`;
|
|
172
|
-
|
|
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>`;
|
|
173
214
|
});
|
|
174
215
|
html += `</div>`;
|
|
175
216
|
}
|
package/src/renderers/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { renderDot } from './dot.js';
|
|
|
5
5
|
import { renderScatter } from './scatter.js';
|
|
6
6
|
import { renderSankey } from './sankey.js';
|
|
7
7
|
import { renderLine } from './line.js';
|
|
8
|
+
import { renderTimeseries } from './timeseries.js';
|
|
9
|
+
import { renderBubble } from './bubble.js';
|
|
8
10
|
|
|
9
11
|
export const renderers = {
|
|
10
12
|
'stacked-bar': renderStackedBar,
|
|
@@ -13,7 +15,9 @@ export const renderers = {
|
|
|
13
15
|
'dot': renderDot,
|
|
14
16
|
'scatter': renderScatter,
|
|
15
17
|
'sankey': renderSankey,
|
|
16
|
-
'line': renderLine
|
|
18
|
+
'line': renderLine,
|
|
19
|
+
'timeseries': renderTimeseries,
|
|
20
|
+
'bubble': renderBubble
|
|
17
21
|
};
|
|
18
22
|
|
|
19
|
-
export { renderStackedBar, renderStackedColumn, renderDonut, renderDot, renderScatter, renderSankey, renderLine };
|
|
23
|
+
export { renderStackedBar, renderStackedColumn, renderDonut, renderDot, renderScatter, renderSankey, renderLine, renderTimeseries, renderBubble };
|
package/src/renderers/line.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { renderDot } from './dot.js';
|
|
2
2
|
|
|
3
3
|
export function renderLine(config) {
|
|
4
|
-
|
|
4
|
+
const connectDots = config.showLines !== false; // default true
|
|
5
|
+
return renderDot({ ...config, connectDots, chartType: 'line' });
|
|
5
6
|
}
|
package/src/renderers/scatter.js
CHANGED
|
@@ -18,7 +18,7 @@ import { getAxisMax, getAxisMin, getAxisTitle, getAxisFormat } from '../config.j
|
|
|
18
18
|
* @returns {string} - HTML string
|
|
19
19
|
*/
|
|
20
20
|
export function renderScatter(config) {
|
|
21
|
-
const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, proportional, _columns } = config;
|
|
21
|
+
const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, proportional, icons, _columns } = config;
|
|
22
22
|
|
|
23
23
|
// Get axis-specific format configs (normalized config provides x.format/y.format)
|
|
24
24
|
const fmtX = getAxisFormat(config, 'x');
|
|
@@ -115,6 +115,13 @@ export function renderScatter(config) {
|
|
|
115
115
|
const seriesList = Array.from(seriesSet);
|
|
116
116
|
const seriesIndex = new Map(seriesList.map((s, i) => [s, i]));
|
|
117
117
|
|
|
118
|
+
// Helper to get icon for a series
|
|
119
|
+
const getSeriesIcon = (seriesName) => {
|
|
120
|
+
if (!icons) return null;
|
|
121
|
+
if (typeof icons === 'string') return icons;
|
|
122
|
+
return icons[seriesName] ?? null;
|
|
123
|
+
};
|
|
124
|
+
|
|
118
125
|
const negativeClasses = (hasNegativeX ? ' has-negative-x' : '') + (hasNegativeY ? ' has-negative-y' : '');
|
|
119
126
|
const proportionalClass = proportional ? ' chart-proportional' : '';
|
|
120
127
|
const idClass = id ? ` chart-${id}` : '';
|
|
@@ -159,6 +166,8 @@ export function renderScatter(config) {
|
|
|
159
166
|
const seriesClass = `chart-series-${slugify(dot.series)}`;
|
|
160
167
|
const fmtXVal = formatNumber(dot.x, fmtX) || dot.x;
|
|
161
168
|
const fmtYVal = formatNumber(dot.y, fmtY) || dot.y;
|
|
169
|
+
const icon = getSeriesIcon(dot.series);
|
|
170
|
+
const iconClass = icon ? ' has-icon' : '';
|
|
162
171
|
|
|
163
172
|
// Build tooltip with optional size value
|
|
164
173
|
let tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
|
|
@@ -173,10 +182,14 @@ export function renderScatter(config) {
|
|
|
173
182
|
styleStr += `; --size-scale: ${dot.sizeScale.toFixed(4)}`;
|
|
174
183
|
}
|
|
175
184
|
|
|
176
|
-
html += `<div class="dot ${colorClass} ${seriesClass}" `;
|
|
185
|
+
html += `<div class="dot ${colorClass} ${seriesClass}${iconClass}" `;
|
|
177
186
|
html += `style="${styleStr}" `;
|
|
178
187
|
html += `title="${escapeHtml(tooltipText)}"`;
|
|
179
|
-
html +=
|
|
188
|
+
html += `>`;
|
|
189
|
+
if (icon) {
|
|
190
|
+
html += `<i class="${escapeHtml(icon)}"></i>`;
|
|
191
|
+
}
|
|
192
|
+
html += `</div>`;
|
|
180
193
|
});
|
|
181
194
|
|
|
182
195
|
html += `</div>`;
|
|
@@ -206,7 +219,13 @@ export function renderScatter(config) {
|
|
|
206
219
|
const label = legendLabels[i] ?? series;
|
|
207
220
|
const colorClass = `chart-color-${i + 1}`;
|
|
208
221
|
const seriesClass = `chart-series-${slugify(series)}`;
|
|
209
|
-
|
|
222
|
+
const icon = getSeriesIcon(series);
|
|
223
|
+
const iconClass = icon ? ' has-icon' : '';
|
|
224
|
+
html += `<span class="chart-legend-item ${colorClass} ${seriesClass}${iconClass}">`;
|
|
225
|
+
if (icon) {
|
|
226
|
+
html += `<i class="${escapeHtml(icon)}"></i>`;
|
|
227
|
+
}
|
|
228
|
+
html += `${escapeHtml(label)}</span>`;
|
|
210
229
|
});
|
|
211
230
|
html += `</div>`;
|
|
212
231
|
}
|