eleventy-plugin-uncharted 0.8.0 → 0.9.1
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 +421 -55
- 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 +60 -40
- package/src/renderers/index.js +6 -2
- package/src/renderers/line.js +2 -1
- 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)
|
|
@@ -18,6 +18,14 @@ import { getAxisMax, getAxisMin, getAxisFormat, getRotateLabels } from '../confi
|
|
|
18
18
|
export function renderDot(config) {
|
|
19
19
|
const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl, connectDots, dots: showDots = true, icons, chartType = 'dot', _columns } = config;
|
|
20
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
|
+
|
|
21
29
|
if (!data || data.length === 0) {
|
|
22
30
|
return `<!-- Dot chart: no data provided -->`;
|
|
23
31
|
}
|
|
@@ -44,16 +52,15 @@ export function renderDot(config) {
|
|
|
44
52
|
const animateClass = animate ? ' chart-animate' : '';
|
|
45
53
|
const rotateLabels = getRotateLabels(config, config.id);
|
|
46
54
|
|
|
47
|
-
// Get Y-axis format
|
|
55
|
+
// Get Y-axis format and axis titles
|
|
48
56
|
const yFormat = getAxisFormat(config, 'y');
|
|
57
|
+
const xAxisTitle = getAxisTitle(config, 'x', '');
|
|
58
|
+
const yAxisTitle = getAxisTitle(config, 'y', '');
|
|
49
59
|
|
|
50
|
-
// Calculate min and max values for Y scaling
|
|
60
|
+
// Calculate min and max values for Y scaling (exclude null values)
|
|
51
61
|
const allValues = data.flatMap(row =>
|
|
52
|
-
seriesKeys.map(key =>
|
|
53
|
-
|
|
54
|
-
return typeof val === 'number' ? val : parseFloat(val) || 0;
|
|
55
|
-
})
|
|
56
|
-
);
|
|
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));
|
|
57
64
|
const dataMax = Math.max(...allValues);
|
|
58
65
|
const dataMin = Math.min(...allValues);
|
|
59
66
|
|
|
@@ -69,8 +76,8 @@ export function renderDot(config) {
|
|
|
69
76
|
const negativeClass = hasNegativeY ? ' has-negative-y' : '';
|
|
70
77
|
const idClass = id ? ` chart-${id}` : '';
|
|
71
78
|
const rotateClass = rotateLabels ? ' rotate-labels' : '';
|
|
72
|
-
//
|
|
73
|
-
const dotsClass =
|
|
79
|
+
// Add no-dots class if dots are disabled (icons only affect legend, not dots)
|
|
80
|
+
const dotsClass = !showDots ? ' no-dots' : '';
|
|
74
81
|
let html = `<figure class="chart chart-${chartType}${animateClass}${negativeClass}${idClass}${rotateClass}${dotsClass}">`;
|
|
75
82
|
|
|
76
83
|
if (title) {
|
|
@@ -90,6 +97,9 @@ export function renderDot(config) {
|
|
|
90
97
|
const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
|
|
91
98
|
html += `<span class="axis-label">${formatNumber(midLabelY, yFormat) || midLabelY}</span>`;
|
|
92
99
|
html += `<span class="axis-label">${formatNumber(minValue, yFormat) || minValue}</span>`;
|
|
100
|
+
if (yAxisTitle) {
|
|
101
|
+
html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
|
|
102
|
+
}
|
|
93
103
|
html += `</div>`;
|
|
94
104
|
|
|
95
105
|
// Scroll wrapper for chart + labels
|
|
@@ -105,6 +115,7 @@ export function renderDot(config) {
|
|
|
105
115
|
html += `<div class="dot-field">`;
|
|
106
116
|
|
|
107
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)
|
|
108
119
|
if (connectDots && data.length > 1) {
|
|
109
120
|
let segIndex = 0;
|
|
110
121
|
seriesKeys.forEach((key, i) => {
|
|
@@ -113,8 +124,13 @@ export function renderDot(config) {
|
|
|
113
124
|
for (let colIndex = 0; colIndex < data.length - 1; colIndex++) {
|
|
114
125
|
const val1 = data[colIndex][key];
|
|
115
126
|
const val2 = data[colIndex + 1][key];
|
|
116
|
-
|
|
117
|
-
|
|
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);
|
|
118
134
|
const y1 = range > 0 ? ((v1 - minValue) / range) * 100 : 0;
|
|
119
135
|
const y2 = range > 0 ? ((v2 - minValue) / range) * 100 : 0;
|
|
120
136
|
const x1 = ((colIndex + 0.5) / data.length) * 100;
|
|
@@ -127,37 +143,38 @@ export function renderDot(config) {
|
|
|
127
143
|
});
|
|
128
144
|
}
|
|
129
145
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
|
|
142
|
-
const colorClass = `chart-color-${i + 1}`;
|
|
143
|
-
const seriesClass = `chart-series-${slugify(key)}`;
|
|
144
|
-
const tooltipLabel = getSeriesLabel(key, i);
|
|
145
|
-
const icon = getSeriesIcon(key);
|
|
146
|
-
const iconClass = icon ? ' has-icon' : '';
|
|
147
|
-
|
|
148
|
-
html += `<div class="dot ${colorClass} ${seriesClass}${iconClass}" `;
|
|
149
|
-
html += `style="--value: ${yPct.toFixed(2)}%" `;
|
|
150
|
-
html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, yFormat) || value}"`;
|
|
151
|
-
html += `>`;
|
|
152
|
-
if (icon) {
|
|
153
|
-
html += `<i class="${escapeHtml(icon)}"></i>`;
|
|
154
|
-
}
|
|
155
|
-
html += `</div>`;
|
|
156
|
-
});
|
|
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
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
|
+
}
|
|
158
173
|
html += `</div>`;
|
|
159
174
|
});
|
|
160
|
-
|
|
175
|
+
|
|
176
|
+
html += `</div>`;
|
|
177
|
+
});
|
|
161
178
|
|
|
162
179
|
html += `</div>`; // close dot-field
|
|
163
180
|
html += `</div>`; // close dot-chart
|
|
@@ -168,6 +185,9 @@ export function renderDot(config) {
|
|
|
168
185
|
const label = row[labelKey] ?? '';
|
|
169
186
|
html += `<span class="dot-label">${escapeHtml(label)}</span>`;
|
|
170
187
|
});
|
|
188
|
+
if (xAxisTitle) {
|
|
189
|
+
html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
|
|
190
|
+
}
|
|
171
191
|
html += `</div>`;
|
|
172
192
|
|
|
173
193
|
html += `</div>`; // close chart-scroll
|
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.lines !== false; // default true
|
|
5
|
+
return renderDot({ ...config, connectDots, chartType: 'line' });
|
|
5
6
|
}
|