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/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 === 'scatter') {
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
- // Try to parse as number, keep as string if not numeric
72
- const num = parseFloat(value);
73
- row[header] = isNaN(num) ? value : num;
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
+ }
@@ -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
- const val = row[key];
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
- const v1 = typeof val1 === 'number' ? val1 : parseFloat(val1) || 0;
109
- const v2 = typeof val2 === 'number' ? val2 : parseFloat(val2) || 0;
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
- // Each row becomes a column with dots for each series
123
- if (showDots) {
124
- data.forEach((row, colIndex) => {
125
- const label = row[labelKey] ?? '';
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
- seriesKeys.forEach((key, i) => {
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
- html += `<div class="dot ${colorClass} ${seriesClass}" `;
138
- html += `style="--value: ${yPct.toFixed(2)}%" `;
139
- html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, yFormat) || value}"`;
140
- html += `></div>`;
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
- html += `<span class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</span>`;
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
  }
@@ -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 };
@@ -1,5 +1,6 @@
1
1
  import { renderDot } from './dot.js';
2
2
 
3
3
  export function renderLine(config) {
4
- return renderDot({ ...config, connectDots: true, chartType: 'line' });
4
+ const connectDots = config.showLines !== false; // default true
5
+ return renderDot({ ...config, connectDots, chartType: 'line' });
5
6
  }
@@ -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 += `></div>`;
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
- html += `<span class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</span>`;
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
  }