eleventy-plugin-uncharted 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of eleventy-plugin-uncharted might be problematic. Click here for more details.

package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A CSS-based chart plugin for Eleventy. Renders charts as pure HTML/CSS.
4
4
 
5
- **[Full Documentation](https://uncharted.docs.seanlunsford.com/)**
5
+ **[Full Documentation](https://uncharted.seanlunsford.com/)**
6
6
 
7
7
  ## Installation
8
8
 
package/css/uncharted.css CHANGED
@@ -7,18 +7,33 @@
7
7
  ========================================================================== */
8
8
 
9
9
  :root {
10
- --chart-color-1: #2196f3; /* Blue */
11
- --chart-color-2: #4caf50; /* Green */
12
- --chart-color-3: #ff7043; /* Orange */
13
- --chart-color-4: #ffc107; /* Amber */
14
- --chart-color-5: #009688; /* Teal */
15
- --chart-color-6: #9c27b0; /* Purple */
16
- --chart-color-7: #e91e63; /* Pink */
17
- --chart-color-8: #3f51b5; /* Indigo */
18
- --chart-color-9: #f44336; /* Red */
19
- --chart-color-10: #00bcd4; /* Cyan */
20
- --chart-color-11: #cddc39; /* Lime */
21
- --chart-color-12: #78909c; /* Gray */
10
+ /* Descriptive color names */
11
+ --chart-color-blue: #2196f3;
12
+ --chart-color-green: #4caf50;
13
+ --chart-color-orange: #ff7043;
14
+ --chart-color-yellow: #ffc107;
15
+ --chart-color-teal: #009688;
16
+ --chart-color-purple: #9c27b0;
17
+ --chart-color-pink: #e91e63;
18
+ --chart-color-indigo: #3f51b5;
19
+ --chart-color-red: #f44336;
20
+ --chart-color-cyan: #00bcd4;
21
+ --chart-color-lime: #cddc39;
22
+ --chart-color-gray: #78909c;
23
+
24
+ /* Numeric aliases (for chart data series) */
25
+ --chart-color-1: var(--chart-color-blue);
26
+ --chart-color-2: var(--chart-color-green);
27
+ --chart-color-3: var(--chart-color-orange);
28
+ --chart-color-4: var(--chart-color-yellow);
29
+ --chart-color-5: var(--chart-color-teal);
30
+ --chart-color-6: var(--chart-color-purple);
31
+ --chart-color-7: var(--chart-color-pink);
32
+ --chart-color-8: var(--chart-color-indigo);
33
+ --chart-color-9: var(--chart-color-red);
34
+ --chart-color-10: var(--chart-color-cyan);
35
+ --chart-color-11: var(--chart-color-lime);
36
+ --chart-color-12: var(--chart-color-gray);
22
37
 
23
38
  /* Backgrounds - neutral with opacity for light/dark adaptability */
24
39
  --chart-bg: rgba(128, 128, 128, 0.15);
@@ -30,6 +45,8 @@
30
45
  --chart-donut-size: 20rem;
31
46
  --chart-donut-hole: 30%;
32
47
  --chart-dot-size: 0.75rem;
48
+ --chart-dot-size-min: 0.375rem;
49
+ --chart-dot-size-max: 1.5rem;
33
50
  --chart-height: 12rem;
34
51
  }
35
52
 
@@ -87,10 +104,28 @@
87
104
  row-gap: 0.375rem;
88
105
  list-style: none;
89
106
  padding: 0;
90
- margin: 0 0 1rem 0;
107
+ margin: 0;
91
108
  font-size: 0.875rem;
92
109
  }
93
110
 
111
+ /* Legends below chart body */
112
+ .chart-body + .chart-legend,
113
+ .chart-body + .chart-legend-title,
114
+ .chart-body + .chart-size-legend,
115
+ .donut-body + .chart-legend {
116
+ margin-top: 1rem;
117
+ }
118
+
119
+ /* Spacing between series legend and size legend */
120
+ .chart-legend + .chart-size-legend {
121
+ margin-top: 0.75rem;
122
+ }
123
+
124
+ /* Bar charts keep legend above */
125
+ .chart-stacked-bar .chart-legend {
126
+ margin-bottom: 1rem;
127
+ }
128
+
94
129
  .chart-legend-item {
95
130
  display: flex;
96
131
  align-items: center;
@@ -119,6 +154,13 @@
119
154
  margin-left: 0.125rem;
120
155
  }
121
156
 
157
+ .chart-legend-title {
158
+ display: block;
159
+ font-size: 0.75rem;
160
+ font-weight: 600;
161
+ margin-bottom: 0.375rem;
162
+ }
163
+
122
164
  /* ==========================================================================
123
165
  Axes
124
166
  ========================================================================== */
@@ -158,9 +200,13 @@
158
200
  transform: translateY(50%);
159
201
  }
160
202
 
203
+ .chart-y-axis:has(.axis-title) {
204
+ padding-left: 1.25rem;
205
+ }
206
+
161
207
  .chart-y-axis .axis-title {
162
208
  position: absolute;
163
- left: -0.5rem;
209
+ left: 0.25rem;
164
210
  top: 50%;
165
211
  transform: rotate(-90deg) translateX(-50%);
166
212
  transform-origin: left center;
@@ -657,6 +703,70 @@
657
703
  z-index: 1;
658
704
  }
659
705
 
706
+ /* Variable-sized dots (size column) */
707
+ .chart-scatter .dot[style*="--size-scale"] {
708
+ --computed-size: calc(
709
+ var(--chart-dot-size-min) +
710
+ var(--size-scale) * (var(--chart-dot-size-max) - var(--chart-dot-size-min))
711
+ );
712
+ width: var(--computed-size);
713
+ height: var(--computed-size);
714
+ }
715
+
716
+ /* Size legend */
717
+ .chart-size-legend {
718
+ display: flex;
719
+ flex-direction: column;
720
+ gap: 0.375rem;
721
+ font-size: 0.875rem;
722
+ }
723
+
724
+ .size-legend-items {
725
+ display: flex;
726
+ align-items: center;
727
+ gap: 1rem;
728
+ }
729
+
730
+ .size-legend-item {
731
+ display: flex;
732
+ align-items: center;
733
+ gap: 0.5rem;
734
+ }
735
+
736
+ .size-dot {
737
+ border-radius: 50%;
738
+ background: currentColor;
739
+ opacity: 0.5;
740
+ }
741
+
742
+ .size-dot-min {
743
+ width: var(--chart-dot-size-min);
744
+ height: var(--chart-dot-size-min);
745
+ }
746
+
747
+ .size-dot-max {
748
+ width: var(--chart-dot-size-max);
749
+ height: var(--chart-dot-size-max);
750
+ }
751
+
752
+ .size-value {
753
+ font-size: 0.75rem;
754
+ opacity: 0.7;
755
+ }
756
+
757
+ /* Proportional scatter: maintain data aspect ratio */
758
+ .chart-scatter.chart-proportional .chart-body,
759
+ .chart-scatter.chart-proportional .chart-y-axis {
760
+ min-height: auto;
761
+ }
762
+
763
+ .chart-scatter.chart-proportional .dot-area {
764
+ aspect-ratio: var(--data-aspect-ratio, 1);
765
+ min-height: auto;
766
+ width: 100%;
767
+ height: auto;
768
+ }
769
+
660
770
  /* ==========================================================================
661
771
  Sankey Chart
662
772
  ========================================================================== */
@@ -761,6 +871,15 @@
761
871
  opacity: 0.8;
762
872
  }
763
873
 
874
+ /* Dim all flows and nodes when hovering a node; per-chart inline styles brighten connected ones */
875
+ .chart-sankey-container:has(.chart-sankey-node:hover) .chart-sankey-flow path {
876
+ opacity: 0.1;
877
+ }
878
+
879
+ .chart-sankey-container:has(.chart-sankey-node:hover) .chart-sankey-node {
880
+ opacity: 0.2;
881
+ }
882
+
764
883
 
765
884
  /* ==========================================================================
766
885
  Animations (Optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
5
5
  "main": "eleventy.config.js",
6
6
  "type": "module",
@@ -100,6 +100,8 @@ export function renderDonut(config) {
100
100
  html += `</div>`;
101
101
  html += `</div>`;
102
102
 
103
+ html += `</div>`; // Close donut-body
104
+
103
105
  // Legend with values (or percentages if showPercentages is true)
104
106
  const legendLabels = legend ?? segments.map(s => s.label);
105
107
  html += `<ul class="chart-legend">`;
@@ -120,8 +122,6 @@ export function renderDonut(config) {
120
122
  });
121
123
  html += `</ul>`;
122
124
 
123
- html += `</div>`; // Close donut-body
124
-
125
125
  html += renderDownloadLink(downloadDataUrl, downloadData);
126
126
  html += `</figure>`;
127
127
 
@@ -15,7 +15,7 @@ import { formatNumber } from '../formatters.js';
15
15
  * @returns {string} - HTML string
16
16
  */
17
17
  export function renderDot(config) {
18
- const { title, subtitle, data, max, min, legend, animate, format, id, rotateLabels, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot' } = config;
18
+ const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, rotateLabels, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot' } = config;
19
19
 
20
20
  if (!data || data.length === 0) {
21
21
  return `<!-- Dot chart: no data provided -->`;
@@ -58,18 +58,6 @@ export function renderDot(config) {
58
58
  html += `</figcaption>`;
59
59
  }
60
60
 
61
- // Legend
62
- if (seriesKeys.length > 0) {
63
- html += `<ul class="chart-legend">`;
64
- seriesKeys.forEach((key, i) => {
65
- const label = legendLabels[i] ?? key;
66
- const colorClass = `chart-color-${i + 1}`;
67
- const seriesClass = `chart-series-${slugify(key)}`;
68
- html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
69
- });
70
- html += `</ul>`;
71
- }
72
-
73
61
  html += `<div class="chart-body">`;
74
62
 
75
63
  // Y-axis
@@ -154,6 +142,22 @@ export function renderDot(config) {
154
142
 
155
143
  html += `</div>`; // close chart-scroll
156
144
  html += `</div>`; // close chart-body
145
+
146
+ // Legend
147
+ if (seriesKeys.length > 0 || legendTitle) {
148
+ if (legendTitle) {
149
+ html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
150
+ }
151
+ html += `<ul class="chart-legend">`;
152
+ seriesKeys.forEach((key, i) => {
153
+ const label = legendLabels[i] ?? key;
154
+ const colorClass = `chart-color-${i + 1}`;
155
+ const seriesClass = `chart-series-${slugify(key)}`;
156
+ html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
157
+ });
158
+ html += `</ul>`;
159
+ }
160
+
157
161
  html += renderDownloadLink(downloadDataUrl, downloadData);
158
162
  html += `</figure>`;
159
163
 
@@ -164,7 +164,7 @@ export function renderSankey(config) {
164
164
  const paddingPct = (nodePadding / 400) * 100; // Convert px to approximate %
165
165
  const minNodeHeight = 2; // Minimum node height in percentage points
166
166
  const minGapHeight = 4; // Minimum gap to prevent label overlap (%)
167
- const minFlowHeightBase = 1.5; // Base minimum flow height (before scaling)
167
+ const minFlowHeightBase = 0.4; // Base minimum flow height (before scaling)
168
168
 
169
169
  // Track maximum height needed across all levels
170
170
  let maxLevelHeight = 100;
@@ -276,105 +276,25 @@ export function renderSankey(config) {
276
276
  return aTargetLevel - bTargetLevel;
277
277
  });
278
278
 
279
- // Calculate flow heights and determine consistent height (max of source/target proportions)
279
+ // Calculate flow heights proportional to each end's node height.
280
+ // Flows taper between source and target, naturally filling both nodes.
280
281
  const flowData = aggregatedEdges.map((edge, index) => {
281
282
  const sourcePos = nodePosition.get(edge.source);
282
283
  const targetPos = nodePosition.get(edge.target);
283
284
  const sourceThroughput = nodeThroughput.get(edge.source);
284
285
  const targetThroughput = nodeThroughput.get(edge.target);
285
286
 
286
- // Flow height proportional to value at each end
287
- const flowHeightSource = (edge.value / sourceThroughput) * sourcePos.height;
288
- const flowHeightTarget = (edge.value / targetThroughput) * targetPos.height;
289
-
290
- // Use max for consistent height across the flow
291
- const height = Math.max(flowHeightSource, flowHeightTarget);
292
-
293
287
  return {
294
288
  ...edge,
295
289
  fromLevel: sourcePos.level,
296
290
  toLevel: targetPos.level,
297
- height,
291
+ fromHeight: (edge.value / sourceThroughput) * sourcePos.height,
292
+ toHeight: (edge.value / targetThroughput) * targetPos.height,
298
293
  index
299
294
  };
300
295
  });
301
296
 
302
- // Check if consistent heights cause overflow and adjust node heights if needed
303
- const nodeOutTotal = new Map(); // Total outflow heights per node
304
- const nodeInTotal = new Map(); // Total inflow heights per node
305
- flowData.forEach(f => {
306
- nodeOutTotal.set(f.source, (nodeOutTotal.get(f.source) || 0) + f.height);
307
- nodeInTotal.set(f.target, (nodeInTotal.get(f.target) || 0) + f.height);
308
- });
309
-
310
- // Expand nodes that need more height for their flows
311
- let needsRepositioning = false;
312
- nodes.forEach(node => {
313
- const pos = nodePosition.get(node);
314
- const outTotal = nodeOutTotal.get(node) || 0;
315
- const inTotal = nodeInTotal.get(node) || 0;
316
- const neededHeight = Math.max(outTotal, inTotal);
317
- if (neededHeight > pos.height) {
318
- pos.height = neededHeight;
319
- needsRepositioning = true;
320
- }
321
- });
322
-
323
- // Reposition nodes within levels if any heights changed
324
- if (needsRepositioning) {
325
- levels.forEach((levelNodes, levelIndex) => {
326
- let currentTop = 0;
327
- levelNodes.forEach(node => {
328
- const pos = nodePosition.get(node);
329
- pos.top = currentTop;
330
- currentTop += pos.height + paddingPct;
331
- });
332
- });
333
-
334
- // Recalculate maxLevelHeight and scale
335
- let newMaxLevelHeight = 100;
336
- levels.forEach(levelNodes => {
337
- if (levelNodes.length === 0) return;
338
- let maxBottom = 0;
339
- levelNodes.forEach(node => {
340
- const pos = nodePosition.get(node);
341
- const bottom = pos.top + pos.height;
342
- if (bottom > maxBottom) maxBottom = bottom;
343
- });
344
- if (maxBottom > newMaxLevelHeight) newMaxLevelHeight = maxBottom;
345
- });
346
-
347
- // Scale if needed
348
- if (newMaxLevelHeight > 100) {
349
- const scale = newMaxLevelHeight / 100;
350
- nodePosition.forEach(pos => {
351
- pos.top = pos.top / scale;
352
- pos.height = pos.height / scale;
353
- });
354
- flowData.forEach(f => {
355
- f.height = f.height / scale;
356
- });
357
- }
358
-
359
- // Re-center levels
360
- levels.forEach(levelNodes => {
361
- if (levelNodes.length === 0) return;
362
- let maxBottom = 0;
363
- levelNodes.forEach(node => {
364
- const pos = nodePosition.get(node);
365
- const bottom = pos.top + pos.height;
366
- if (bottom > maxBottom) maxBottom = bottom;
367
- });
368
- const centerOffset = (100 - maxBottom) / 2;
369
- if (centerOffset > 0) {
370
- levelNodes.forEach(node => {
371
- nodePosition.get(node).top += centerOffset;
372
- });
373
- }
374
- });
375
- }
376
-
377
- // Calculate flow positions with consistent heights
297
+ // Calculate flow positions within each node
378
298
  const nodeOutOffset = new Map();
379
299
  const nodeInOffset = new Map();
380
300
  nodes.forEach(n => {
@@ -389,8 +309,8 @@ export function renderSankey(config) {
389
309
  const sourceOffset = nodeOutOffset.get(f.source);
390
310
  const targetOffset = nodeInOffset.get(f.target);
391
311
 
392
- nodeOutOffset.set(f.source, sourceOffset + f.height);
393
- nodeInOffset.set(f.target, targetOffset + f.height);
312
+ nodeOutOffset.set(f.source, sourceOffset + f.fromHeight);
313
+ nodeInOffset.set(f.target, targetOffset + f.toHeight);
394
314
 
395
315
  return {
396
316
  ...f,
@@ -495,21 +415,28 @@ export function renderSankey(config) {
495
415
  html += `</figcaption>`;
496
416
  }
497
417
 
498
- // Legend (optional)
499
- if (legend) {
500
- html += `<ul class="chart-legend">`;
501
- nodes.forEach((node, i) => {
502
- const colorClass = `chart-color-${nodeColors.get(node)}`;
503
- const seriesClass = `chart-series-${slugify(node)}`;
504
- const throughput = nodeThroughput.get(node);
505
- html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(node)}`;
506
- if (format) {
507
- html += ` <span class="legend-value">${formatNumber(throughput, format) || throughput}</span>`;
508
- }
509
- html += `</li>`;
418
+ // Build neighbor map for node hover highlighting
419
+ const nodeNeighbors = new Map();
420
+ nodes.forEach(n => nodeNeighbors.set(n, new Set()));
421
+ aggregatedEdges.forEach(({ source, target }) => {
422
+ nodeNeighbors.get(source).add(target);
423
+ nodeNeighbors.get(target).add(source);
424
+ });
425
+
426
+ // Generate per-node hover rules: brighten connected flows and nodes, dim the rest
427
+ html += `<style>`;
428
+ nodes.forEach(node => {
429
+ const slug = slugify(node);
430
+ const prefix = `.chart-sankey-container:has(.chart-series-${slug}:hover)`;
431
+ // Brighten connected flows
432
+ html += `${prefix} .chart-flow-${slug} path{opacity:0.8}`;
433
+ // Brighten hovered node + its neighbors
434
+ html += `${prefix} .chart-series-${slug}{opacity:1}`;
435
+ nodeNeighbors.get(node).forEach(neighbor => {
436
+ html += `${prefix} .chart-series-${slugify(neighbor)}{opacity:1}`;
510
437
  });
511
- html += `</ul>`;
512
- }
438
+ });
439
+ html += `</style>`;
513
440
 
514
441
  html += `<div class="chart-sankey-container">`;
515
442
 
@@ -527,19 +454,23 @@ export function renderSankey(config) {
527
454
  // SVG path coordinates (0-100 viewBox)
528
455
  const y1 = flow.fromTop;
529
456
  const y2 = flow.toTop;
530
- const h = flow.height;
457
+ const fh = flow.fromHeight;
458
+ const th = flow.toHeight;
531
459
 
532
460
  // Bezier control points at 40% and 60% for smooth S-curve
533
461
  const cx1 = 40;
534
462
  const cx2 = 60;
535
463
 
536
464
  // Path: top edge (left to right with curve), then bottom edge (right to left with curve)
465
+ // Flow tapers between fromHeight at source and toHeight at target
537
466
  // Extend slightly past 0/100 to overlap with node columns and prevent subpixel gaps
538
467
  const x0 = -2;
539
468
  const x1end = 102;
540
- const pathD = `M ${x0},${y1.toFixed(2)} C ${cx1},${y1.toFixed(2)} ${cx2},${y2.toFixed(2)} ${x1end},${y2.toFixed(2)} L ${x1end},${(y2 + h).toFixed(2)} C ${cx2},${(y2 + h).toFixed(2)} ${cx1},${(y1 + h).toFixed(2)} ${x0},${(y1 + h).toFixed(2)} Z`;
469
+ const pathD = `M ${x0},${y1.toFixed(2)} C ${cx1},${y1.toFixed(2)} ${cx2},${y2.toFixed(2)} ${x1end},${y2.toFixed(2)} L ${x1end},${(y2 + th).toFixed(2)} C ${cx2},${(y2 + th).toFixed(2)} ${cx1},${(y1 + fh).toFixed(2)} ${x0},${(y1 + fh).toFixed(2)} Z`;
541
470
 
542
- html += `<svg class="chart-sankey-flow" viewBox="0 0 100 100" preserveAspectRatio="none" `;
471
+ const sourceSlug = slugify(flow.source);
472
+ const targetSlug = slugify(flow.target);
473
+ html += `<svg class="chart-sankey-flow chart-flow-${sourceSlug} chart-flow-${targetSlug}" viewBox="0 0 100 100" preserveAspectRatio="none" `;
543
474
  html += `style="grid-column: ${colStart} / ${colEnd}; --from-level: ${flow.fromLevel}; --flow-index: ${i}; --delay-step: ${delayStep.toFixed(3)}s">`;
544
475
  html += `<defs><linearGradient id="sankey-grad-${id || 'default'}-${i}">`;
545
476
  html += `<stop offset="0%" style="stop-color: var(--chart-color-${sourceColor})" />`;
@@ -574,6 +505,23 @@ export function renderSankey(config) {
574
505
  });
575
506
 
576
507
  html += `</div>`;
508
+
509
+ // Legend (optional)
510
+ if (legend) {
511
+ html += `<ul class="chart-legend">`;
512
+ nodes.forEach((node, i) => {
513
+ const colorClass = `chart-color-${nodeColors.get(node)}`;
514
+ const seriesClass = `chart-series-${slugify(node)}`;
515
+ const throughput = nodeThroughput.get(node);
516
+ html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(node)}`;
517
+ if (format) {
518
+ html += ` <span class="legend-value">${formatNumber(throughput, format) || throughput}</span>`;
519
+ }
520
+ html += `</li>`;
521
+ });
522
+ html += `</ul>`;
523
+ }
524
+
577
525
  html += renderDownloadLink(downloadDataUrl, downloadData);
578
526
  html += `</figure>`;
579
527
 
@@ -6,19 +6,21 @@ import { formatNumber } from '../formatters.js';
6
6
  * @param {Object} config - Chart configuration
7
7
  * @param {string} config.title - Chart title
8
8
  * @param {string} [config.subtitle] - Chart subtitle
9
- * @param {Object[]} config.data - Chart data (positional: label, x, y, series)
9
+ * @param {Object[]} config.data - Chart data (label + named columns: x, y, size, series)
10
10
  * @param {number} [config.maxX] - Maximum X value (defaults to max in data)
11
11
  * @param {number} [config.maxY] - Maximum Y value (defaults to max in data)
12
12
  * @param {number} [config.minX] - Minimum X value (defaults to min in data or 0)
13
13
  * @param {number} [config.minY] - Minimum Y value (defaults to min in data or 0)
14
14
  * @param {string[]} [config.legend] - Legend labels for series
15
+ * @param {string} [config.legendTitle] - Title for series legend
16
+ * @param {string} [config.sizeTitle] - Title for size legend (enables size legend display)
15
17
  * @param {boolean} [config.animate] - Enable animations
16
18
  * @param {string} [config.titleX] - X-axis title (defaults to column name)
17
19
  * @param {string} [config.titleY] - Y-axis title (defaults to column name)
18
20
  * @returns {string} - HTML string
19
21
  */
20
22
  export function renderScatter(config) {
21
- const { title, subtitle, data, maxX, maxY, minX, minY, legend, animate, format, titleX, titleY, id, downloadData, downloadDataUrl } = config;
23
+ const { title, subtitle, data, maxX, maxY, minX, minY, legend, legendTitle, sizeTitle, animate, format, titleX, titleY, id, downloadData, downloadDataUrl, proportional } = config;
22
24
 
23
25
  // Handle nested X/Y format for scatter charts
24
26
  const fmtX = format?.x || format || {};
@@ -30,25 +32,52 @@ export function renderScatter(config) {
30
32
 
31
33
  const animateClass = animate ? ' chart-animate' : '';
32
34
 
33
- // Get column keys positionally
35
+ // Named column detection (case-insensitive), with positional fallback for x/y
34
36
  const keys = Object.keys(data[0]);
35
- const labelKey = keys[0]; // First column: point labels
36
- const xKey = keys[1]; // Second column: X values
37
- const yKey = keys[2]; // Third column: Y values
38
- const seriesKey = keys[3]; // Fourth column (optional): series
37
+ const findKey = name => keys.find(k => k.toLowerCase() === name) || null;
38
+
39
+ // First column is always label
40
+ const labelKey = keys[0];
41
+
42
+ // X and Y: named if both exist, otherwise positional (columns 2 and 3)
43
+ const namedX = findKey('x');
44
+ const namedY = findKey('y');
45
+ const xKey = (namedX && namedY) ? namedX : keys[1];
46
+ const yKey = (namedX && namedY) ? namedY : keys[2];
47
+
48
+ // Size and series: named only (no positional fallback)
49
+ const sizeKey = findKey('size');
50
+ const seriesKey = findKey('series');
39
51
 
40
52
  // Axis titles: explicit config overrides column names
41
53
  const xAxisTitle = titleX ?? xKey;
42
54
  const yAxisTitle = titleY ?? yKey;
43
55
 
44
- // Map data to dots using positional columns
56
+ // Map data to dots
45
57
  const dots = data.map(item => ({
46
58
  label: item[labelKey] ?? '',
47
59
  x: typeof item[xKey] === 'number' ? item[xKey] : parseFloat(item[xKey]) || 0,
48
60
  y: typeof item[yKey] === 'number' ? item[yKey] : parseFloat(item[yKey]) || 0,
61
+ rawSize: sizeKey ? (typeof item[sizeKey] === 'number' ? item[sizeKey] : parseFloat(item[sizeKey]) || 0) : null,
49
62
  series: seriesKey ? (item[seriesKey] ?? 'default') : 'default'
50
63
  }));
51
64
 
65
+ // Size normalization: non-positive values get minimum size (scale 0)
66
+ if (sizeKey) {
67
+ const sizeValues = dots.map(d => d.rawSize).filter(v => v > 0);
68
+ const minSizeVal = sizeValues.length ? Math.min(...sizeValues) : 1;
69
+ const maxSizeVal = sizeValues.length ? Math.max(...sizeValues) : 1;
70
+ const sizeRange = maxSizeVal - minSizeVal;
71
+
72
+ dots.forEach(dot => {
73
+ if (dot.rawSize <= 0 || sizeRange === 0) {
74
+ dot.sizeScale = 0;
75
+ } else {
76
+ dot.sizeScale = (dot.rawSize - minSizeVal) / sizeRange;
77
+ }
78
+ });
79
+ }
80
+
52
81
  // Calculate bounds
53
82
  const xValues = dots.map(d => d.x);
54
83
  const yValues = dots.map(d => d.y);
@@ -63,6 +92,7 @@ export function renderScatter(config) {
63
92
  const calcMinY = minY ?? (dataMinY < 0 ? dataMinY : 0);
64
93
  const rangeX = calcMaxX - calcMinX;
65
94
  const rangeY = calcMaxY - calcMinY;
95
+ const dataAspectRatio = rangeY > 0 ? rangeX / rangeY : 1;
66
96
 
67
97
  const hasNegativeX = calcMinX < 0;
68
98
  const hasNegativeY = calcMinY < 0;
@@ -77,8 +107,9 @@ export function renderScatter(config) {
77
107
  const seriesIndex = new Map(seriesList.map((s, i) => [s, i]));
78
108
 
79
109
  const negativeClasses = (hasNegativeX ? ' has-negative-x' : '') + (hasNegativeY ? ' has-negative-y' : '');
110
+ const proportionalClass = proportional ? ' chart-proportional' : '';
80
111
  const idClass = id ? ` chart-${id}` : '';
81
- let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}${idClass}">`;
112
+ let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}${proportionalClass}${idClass}">`;
82
113
 
83
114
  if (title) {
84
115
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -88,19 +119,6 @@ export function renderScatter(config) {
88
119
  html += `</figcaption>`;
89
120
  }
90
121
 
91
- // Legend (if multiple series)
92
- if (seriesList.length > 1 || legend) {
93
- const legendLabels = legend ?? seriesList;
94
- html += `<ul class="chart-legend">`;
95
- seriesList.forEach((series, i) => {
96
- const label = legendLabels[i] ?? series;
97
- const colorClass = `chart-color-${i + 1}`;
98
- const seriesClass = `chart-series-${slugify(series)}`;
99
- html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
100
- });
101
- html += `</ul>`;
102
- }
103
-
104
122
  html += `<div class="chart-body">`;
105
123
 
106
124
  // Y-axis
@@ -119,9 +137,11 @@ export function renderScatter(config) {
119
137
  if (hasNegativeY) containerStyles.push(`--zero-position-y: ${zeroPctY.toFixed(2)}%`);
120
138
  const containerStyle = containerStyles.length > 0 ? ` style="${containerStyles.join('; ')}"` : '';
121
139
  html += `<div class="scatter-container"${containerStyle}>`;
122
- html += `<div class="dot-area">`;
140
+ const dotAreaStyle = proportional ? ` style="--data-aspect-ratio: ${dataAspectRatio.toFixed(4)}"` : '';
141
+ html += `<div class="dot-area"${dotAreaStyle}>`;
123
142
  html += `<div class="dot-field">`;
124
143
 
144
+ const fmtSize = format?.size || {};
125
145
  dots.forEach((dot, i) => {
126
146
  const xPct = rangeX > 0 ? ((dot.x - calcMinX) / rangeX) * 100 : 0;
127
147
  const yPct = rangeY > 0 ? ((dot.y - calcMinY) / rangeY) * 100 : 0;
@@ -130,10 +150,22 @@ export function renderScatter(config) {
130
150
  const seriesClass = `chart-series-${slugify(dot.series)}`;
131
151
  const fmtXVal = formatNumber(dot.x, fmtX) || dot.x;
132
152
  const fmtYVal = formatNumber(dot.y, fmtY) || dot.y;
133
- const tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
153
+
154
+ // Build tooltip with optional size value
155
+ let tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
156
+ if (sizeKey && dot.rawSize !== null) {
157
+ const fmtSizeVal = formatNumber(dot.rawSize, fmtSize) || dot.rawSize;
158
+ tooltipText += ` [${fmtSizeVal}]`;
159
+ }
160
+
161
+ // Build style string with optional size scale
162
+ let styleStr = `--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%`;
163
+ if (sizeKey) {
164
+ styleStr += `; --size-scale: ${dot.sizeScale.toFixed(4)}`;
165
+ }
134
166
 
135
167
  html += `<div class="dot ${colorClass} ${seriesClass}" `;
136
- html += `style="--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%" `;
168
+ html += `style="${styleStr}" `;
137
169
  html += `title="${escapeHtml(tooltipText)}"`;
138
170
  html += `></div>`;
139
171
  });
@@ -153,6 +185,41 @@ export function renderScatter(config) {
153
185
 
154
186
  html += `</div>`;
155
187
  html += `</div>`;
188
+
189
+ // Legend (if multiple series or legendTitle specified)
190
+ if (seriesList.length > 1 || legend || legendTitle) {
191
+ const legendLabels = legend ?? seriesList;
192
+ if (legendTitle) {
193
+ html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
194
+ }
195
+ html += `<ul class="chart-legend">`;
196
+ seriesList.forEach((series, i) => {
197
+ const label = legendLabels[i] ?? series;
198
+ const colorClass = `chart-color-${i + 1}`;
199
+ const seriesClass = `chart-series-${slugify(series)}`;
200
+ html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
201
+ });
202
+ html += `</ul>`;
203
+ }
204
+
205
+ // Size legend (when sizeTitle is specified and size column exists)
206
+ if (sizeTitle && sizeKey) {
207
+ const sizeValues = dots.map(d => d.rawSize).filter(v => v > 0);
208
+ const minSizeVal = sizeValues.length ? Math.min(...sizeValues) : 0;
209
+ const maxSizeVal = sizeValues.length ? Math.max(...sizeValues) : 0;
210
+ const fmtSizeLegend = format?.size || format || {};
211
+ const minFormatted = formatNumber(minSizeVal, fmtSizeLegend) || minSizeVal;
212
+ const maxFormatted = formatNumber(maxSizeVal, fmtSizeLegend) || maxSizeVal;
213
+
214
+ html += `<div class="chart-size-legend">`;
215
+ html += `<span class="chart-legend-title">${escapeHtml(sizeTitle)}</span>`;
216
+ html += `<div class="size-legend-items">`;
217
+ html += `<span class="size-legend-item"><span class="size-dot size-dot-min"></span><span class="size-value">${minFormatted}</span></span>`;
218
+ html += `<span class="size-legend-item"><span class="size-dot size-dot-max"></span><span class="size-value">${maxFormatted}</span></span>`;
219
+ html += `</div>`;
220
+ html += `</div>`;
221
+ }
222
+
156
223
  html += renderDownloadLink(downloadDataUrl, downloadData);
157
224
  html += `</figure>`;
158
225
 
@@ -13,7 +13,7 @@ import { formatNumber } from '../formatters.js';
13
13
  * @returns {string} - HTML string
14
14
  */
15
15
  export function renderStackedBar(config) {
16
- const { title, subtitle, data, max, legend, animate, format, id, downloadData, downloadDataUrl } = config;
16
+ const { title, subtitle, data, max, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl } = config;
17
17
 
18
18
  if (!data || data.length === 0) {
19
19
  return `<!-- Stacked bar chart: no data provided -->`;
@@ -46,7 +46,10 @@ export function renderStackedBar(config) {
46
46
  }
47
47
 
48
48
  // Legend
49
- if (seriesKeys.length > 0) {
49
+ if (seriesKeys.length > 0 || legendTitle) {
50
+ if (legendTitle) {
51
+ html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
52
+ }
50
53
  html += `<ul class="chart-legend">`;
51
54
  seriesKeys.forEach((key, i) => {
52
55
  const label = legendLabels[i] ?? key;
@@ -67,18 +67,6 @@ export function renderStackedColumn(config) {
67
67
  html += `</figcaption>`;
68
68
  }
69
69
 
70
- // Legend
71
- if (seriesKeys.length > 0) {
72
- html += `<ul class="chart-legend">`;
73
- seriesKeys.forEach((key, i) => {
74
- const label = legendLabels[i] ?? key;
75
- const colorClass = `chart-color-${i + 1}`;
76
- const seriesClass = `chart-series-${slugify(key)}`;
77
- html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
78
- });
79
- html += `</ul>`;
80
- }
81
-
82
70
  html += `<div class="chart-body">`;
83
71
 
84
72
  // Y-axis with --zero-position for label positioning
@@ -192,6 +180,19 @@ export function renderStackedColumn(config) {
192
180
 
193
181
  html += `</div>`; // close chart-scroll
194
182
  html += `</div>`; // close chart-body
183
+
184
+ // Legend
185
+ if (seriesKeys.length > 0) {
186
+ html += `<ul class="chart-legend">`;
187
+ seriesKeys.forEach((key, i) => {
188
+ const label = legendLabels[i] ?? key;
189
+ const colorClass = `chart-color-${i + 1}`;
190
+ const seriesClass = `chart-series-${slugify(key)}`;
191
+ html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
192
+ });
193
+ html += `</ul>`;
194
+ }
195
+
195
196
  html += renderDownloadLink(downloadDataUrl, downloadData);
196
197
  html += `</figure>`;
197
198