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/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)
@@ -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
- const val = row[key];
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
- // Only add no-dots class if dots are disabled AND no icons are set
73
- const dotsClass = (!showDots && !icons) ? ' no-dots' : '';
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
- const v1 = typeof val1 === 'number' ? val1 : parseFloat(val1) || 0;
117
- 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);
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
- // Each row becomes a column with dots for each series
131
- // Show dots if explicitly enabled OR if icons are set (icons override dots: false)
132
- if (showDots || icons) {
133
- data.forEach((row, colIndex) => {
134
- const label = row[labelKey] ?? '';
135
-
136
- html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
137
-
138
- seriesKeys.forEach((key, i) => {
139
- const val = row[key];
140
- const value = typeof val === 'number' ? val : parseFloat(val) || 0;
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
@@ -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.lines !== false; // default true
5
+ return renderDot({ ...config, connectDots, chartType: 'line' });
5
6
  }