eleventy-plugin-uncharted 0.5.1 → 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
  ========================================================================== */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.5.1",
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
 
@@ -415,22 +415,6 @@ export function renderSankey(config) {
415
415
  html += `</figcaption>`;
416
416
  }
417
417
 
418
- // Legend (optional)
419
- if (legend) {
420
- html += `<ul class="chart-legend">`;
421
- nodes.forEach((node, i) => {
422
- const colorClass = `chart-color-${nodeColors.get(node)}`;
423
- const seriesClass = `chart-series-${slugify(node)}`;
424
- const throughput = nodeThroughput.get(node);
425
- html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(node)}`;
426
- if (format) {
427
- html += ` <span class="legend-value">${formatNumber(throughput, format) || throughput}</span>`;
428
- }
429
- html += `</li>`;
430
- });
431
- html += `</ul>`;
432
- }
433
-
434
418
  // Build neighbor map for node hover highlighting
435
419
  const nodeNeighbors = new Map();
436
420
  nodes.forEach(n => nodeNeighbors.set(n, new Set()));
@@ -521,6 +505,23 @@ export function renderSankey(config) {
521
505
  });
522
506
 
523
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
+
524
525
  html += renderDownloadLink(downloadDataUrl, downloadData);
525
526
  html += `</figure>`;
526
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