eleventy-plugin-uncharted 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/css/uncharted.css CHANGED
@@ -107,8 +107,9 @@
107
107
  flex-shrink: 0;
108
108
  }
109
109
 
110
- /* Dot/scatter charts use circular legend markers */
110
+ /* Dot/scatter/line charts use circular legend markers */
111
111
  .chart-dot .chart-legend-item::before,
112
+ .chart-line .chart-legend-item::before,
112
113
  .chart-scatter .chart-legend-item::before {
113
114
  border-radius: 50%;
114
115
  }
@@ -476,18 +477,18 @@
476
477
  Dot Chart (Categorical - columns with dots at Y positions)
477
478
  ========================================================================== */
478
479
 
479
- .chart-dot .chart-body {
480
+ :is(.chart-dot, .chart-line) .chart-body {
480
481
  display: grid;
481
482
  grid-template-columns: auto 1fr;
482
483
  grid-template-rows: 1fr auto;
483
484
  }
484
485
 
485
- .chart-dot .chart-y-axis {
486
+ :is(.chart-dot, .chart-line) .chart-y-axis {
486
487
  grid-row: 1;
487
488
  grid-column: 1;
488
489
  }
489
490
 
490
- .chart-dot .chart-scroll {
491
+ :is(.chart-dot, .chart-line) .chart-scroll {
491
492
  grid-row: 1 / -1;
492
493
  grid-column: 2;
493
494
  display: grid;
@@ -496,7 +497,7 @@
496
497
  overflow-y: visible;
497
498
  }
498
499
 
499
- .chart-dot .dot-chart {
500
+ :is(.chart-dot, .chart-line) .dot-chart {
500
501
  grid-row: 1;
501
502
  position: relative;
502
503
  min-height: var(--chart-height);
@@ -506,7 +507,7 @@
506
507
  }
507
508
 
508
509
  /* Inner field sized to content area - dots position relative to this */
509
- .chart-dot .dot-field {
510
+ :is(.chart-dot, .chart-line) .dot-field {
510
511
  position: absolute;
511
512
  top: 0.5rem;
512
513
  right: 0.5rem;
@@ -517,13 +518,13 @@
517
518
  gap: 6px;
518
519
  }
519
520
 
520
- .chart-dot .dot-col {
521
+ :is(.chart-dot, .chart-line) .dot-col {
521
522
  flex: 1;
522
523
  position: relative;
523
524
  min-width: 1.5rem;
524
525
  }
525
526
 
526
- .chart-dot .dot {
527
+ :is(.chart-dot, .chart-line) .dot {
527
528
  width: var(--chart-dot-size);
528
529
  height: var(--chart-dot-size);
529
530
  border-radius: 50%;
@@ -535,12 +536,12 @@
535
536
  background-color: var(--color);
536
537
  }
537
538
 
538
- .chart-dot .dot[title]:hover {
539
+ :is(.chart-dot, .chart-line) .dot[title]:hover {
539
540
  transform: translate(-50%, 50%) scale(1.3);
540
541
  z-index: 1;
541
542
  }
542
543
 
543
- .chart-dot .dot-labels {
544
+ :is(.chart-dot, .chart-line) .dot-labels {
544
545
  grid-row: 2;
545
546
  display: flex;
546
547
  gap: 6px;
@@ -548,18 +549,18 @@
548
549
  margin-top: 0.5rem;
549
550
  }
550
551
 
551
- .chart-dot .dot-label {
552
+ :is(.chart-dot, .chart-line) .dot-label {
552
553
  flex: 1;
553
554
  min-width: 1.5rem;
554
555
  }
555
556
 
556
557
  /* Rotated dot labels (opt-in via rotateLabels config) */
557
- .chart-dot.rotate-labels .dot-labels {
558
+ :is(.chart-dot, .chart-line).rotate-labels .dot-labels {
558
559
  padding-top: 0.5rem;
559
560
  align-items: flex-start;
560
561
  }
561
562
 
562
- .chart-dot.rotate-labels .dot-label {
563
+ :is(.chart-dot, .chart-line).rotate-labels .dot-label {
563
564
  writing-mode: vertical-rl;
564
565
  transform: rotate(180deg);
565
566
  display: flex;
@@ -568,6 +569,33 @@
568
569
  text-overflow: clip;
569
570
  }
570
571
 
572
+ /* ==========================================================================
573
+ Line Chart (CSS segments connecting dots)
574
+ ========================================================================== */
575
+
576
+ .chart-line .dot-field {
577
+ container-type: size;
578
+ }
579
+
580
+ .chart-line .chart-line-segment {
581
+ position: absolute;
582
+ left: calc(var(--x1) * 1%);
583
+ bottom: calc(var(--y1) * 1%);
584
+ width: hypot(calc((var(--x2) - var(--x1)) * 1cqw), calc((var(--y2) - var(--y1)) * 1cqh));
585
+ height: 2px;
586
+ background-color: var(--color);
587
+ transform-origin: left center;
588
+ transform: translateY(50%) rotate(
589
+ atan2(calc((var(--y1) - var(--y2)) * 1cqh), calc((var(--x2) - var(--x1)) * 1cqw))
590
+ );
591
+ pointer-events: none;
592
+ }
593
+
594
+ /* Hide dots when dots: false */
595
+ .chart-line.no-dots .dot {
596
+ display: none;
597
+ }
598
+
571
599
  /* ==========================================================================
572
600
  Scatter Chart (Continuous X and Y axes)
573
601
  ========================================================================== */
@@ -629,6 +657,120 @@
629
657
  z-index: 1;
630
658
  }
631
659
 
660
+ /* ==========================================================================
661
+ Sankey Chart
662
+ ========================================================================== */
663
+
664
+ .chart-sankey {
665
+ --sankey-node-width: var(--node-width, 20px);
666
+ --sankey-flow-opacity: 0.5;
667
+ --sankey-min-height: 16rem;
668
+ }
669
+
670
+ .chart-sankey-container {
671
+ position: relative;
672
+ display: grid;
673
+ grid-template-columns: var(--grid-columns);
674
+ min-height: calc(var(--sankey-min-height) * var(--height-scale, 1));
675
+ padding: 1rem 0;
676
+ overflow-x: auto;
677
+ }
678
+
679
+ .chart-sankey-level {
680
+ position: relative;
681
+ display: flex;
682
+ flex-direction: column;
683
+ min-width: var(--sankey-node-width);
684
+ grid-row: 1;
685
+ z-index: 1;
686
+ }
687
+
688
+ .chart-sankey-node {
689
+ position: absolute;
690
+ top: var(--top);
691
+ height: var(--height);
692
+ width: var(--sankey-node-width);
693
+ background-color: var(--color);
694
+ display: flex;
695
+ align-items: center;
696
+ cursor: default;
697
+ }
698
+
699
+ /* Round outer corners only (where no flows connect) */
700
+ .chart-sankey-level-first .chart-sankey-node {
701
+ border-radius: 3px 0 0 3px;
702
+ }
703
+
704
+ .chart-sankey-level-last .chart-sankey-node {
705
+ border-radius: 0 3px 3px 0;
706
+ }
707
+
708
+ .chart-sankey-node-label {
709
+ position: absolute;
710
+ white-space: nowrap;
711
+ font-size: 0.75rem;
712
+ padding: 0 0.5rem;
713
+ }
714
+
715
+ /* Labels for first level (sources) - positioned to the right */
716
+ .chart-sankey-level-first .chart-sankey-node-label {
717
+ left: 100%;
718
+ text-align: left;
719
+ }
720
+
721
+ /* Labels for last level (sinks) - positioned to the left (inside) */
722
+ .chart-sankey-level-last .chart-sankey-node-label {
723
+ right: 100%;
724
+ text-align: right;
725
+ }
726
+
727
+ /* Labels for middle levels - positioned to the right */
728
+ .chart-sankey-level:not(.chart-sankey-level-first):not(.chart-sankey-level-last) .chart-sankey-node-label {
729
+ left: 100%;
730
+ text-align: left;
731
+ }
732
+
733
+ /* Option: last level labels outside (on the right) */
734
+ .chart-sankey-end-labels-outside .chart-sankey-level-last .chart-sankey-node-label {
735
+ right: auto;
736
+ left: 100%;
737
+ text-align: left;
738
+ }
739
+
740
+ .chart-sankey-end-labels-outside .chart-sankey-container {
741
+ padding-right: var(--end-label-width);
742
+ }
743
+
744
+ /* Flows as SVG grid children */
745
+ .chart-sankey-flow {
746
+ grid-row: 1 / -1; /* Span full height */
747
+ width: 100%;
748
+ height: 100%;
749
+ z-index: 0;
750
+ overflow: visible;
751
+ pointer-events: none; /* Pass through to path children */
752
+ }
753
+
754
+ .chart-sankey-flow path {
755
+ opacity: var(--sankey-flow-opacity);
756
+ pointer-events: auto;
757
+ cursor: default;
758
+ }
759
+
760
+ .chart-sankey-flow path:hover {
761
+ opacity: 0.8;
762
+ }
763
+
764
+ /* Dim all flows and nodes when hovering a node; per-chart inline styles brighten connected ones */
765
+ .chart-sankey-container:has(.chart-sankey-node:hover) .chart-sankey-flow path {
766
+ opacity: 0.1;
767
+ }
768
+
769
+ .chart-sankey-container:has(.chart-sankey-node:hover) .chart-sankey-node {
770
+ opacity: 0.2;
771
+ }
772
+
773
+
632
774
  /* ==========================================================================
633
775
  Animations (Optional)
634
776
  Add 'animate: true' to chart config to enable
@@ -728,6 +870,17 @@
728
870
  }
729
871
  }
730
872
 
873
+ /* Line chart: clip-path sweep reveals lines and dots left-to-right */
874
+ .chart-animate.chart-line .dot-field {
875
+ clip-path: inset(calc(var(--chart-dot-size) * -0.5) 100% calc(var(--chart-dot-size) * -0.5) 0);
876
+ animation: line-reveal 1.5s cubic-bezier(0.25, 1, 0.5, 1) forwards;
877
+ }
878
+
879
+ @keyframes line-reveal {
880
+ from { clip-path: inset(calc(var(--chart-dot-size) * -0.5) 100% calc(var(--chart-dot-size) * -0.5) 0); }
881
+ to { clip-path: inset(calc(var(--chart-dot-size) * -0.5) 0 calc(var(--chart-dot-size) * -0.5) 0); }
882
+ }
883
+
731
884
  /* Donut chart: clockwise reveal using animated mask */
732
885
  @property --donut-reveal {
733
886
  syntax: '<angle>';
@@ -771,25 +924,47 @@
771
924
  }
772
925
  }
773
926
 
927
+ /* Sankey chart: SVG flows reveal from left to right */
928
+ .chart-animate.chart-sankey .chart-sankey-flow {
929
+ clip-path: inset(0 100% 0 0);
930
+ animation: sankey-flow-reveal 0.8s cubic-bezier(0.25, 1, 0.5, 1) forwards;
931
+ animation-delay: calc(var(--from-level, 0) * 0.2s + var(--flow-index, 0) * var(--delay-step, 0.05s));
932
+ }
933
+
934
+ @keyframes sankey-flow-reveal {
935
+ from { clip-path: inset(0 100% 0 0); }
936
+ to { clip-path: inset(0 0 0 0); }
937
+ }
938
+
774
939
  /* Reduced motion preference */
775
940
  @media (prefers-reduced-motion: reduce) {
776
941
  .chart-animate .bar-fills,
777
942
  .chart-animate .column-track,
778
943
  .chart-animate.chart-dot .dot,
779
944
  .chart-animate.chart-scatter .dot,
780
- .chart-animate .donut-ring::before {
945
+ .chart-animate .donut-ring::before,
946
+ .chart-animate.chart-sankey .chart-sankey-flow,
947
+ .chart-animate.chart-line .dot-field {
781
948
  animation: none;
782
949
  }
783
950
 
784
951
  .chart-animate.chart-dot .dot,
785
952
  .chart-animate.chart-scatter .dot {
786
953
  opacity: 1;
787
- transform: none;
954
+ transform: translate(-50%, 50%);
955
+ }
956
+
957
+ .chart-animate.chart-line .dot-field {
958
+ clip-path: none;
788
959
  }
789
960
 
790
961
  .chart-animate.has-negative-y .column-track {
791
962
  clip-path: none;
792
963
  }
964
+
965
+ .chart-animate.chart-sankey .chart-sankey-flow {
966
+ clip-path: none;
967
+ }
793
968
  }
794
969
 
795
970
  /* ==========================================================================
@@ -857,7 +1032,7 @@
857
1032
  }
858
1033
 
859
1034
  /* Expand dot-field insets for negative values */
860
- .chart-dot.has-negative-y .dot-field,
1035
+ :is(.chart-dot, .chart-line).has-negative-y .dot-field,
861
1036
  .chart-scatter.has-negative-y .dot-field {
862
1037
  bottom: 0.5rem;
863
1038
  }
@@ -867,13 +1042,13 @@
867
1042
  }
868
1043
 
869
1044
  /* Y-axis padding adjustment for negative values */
870
- .chart-dot.has-negative-y .chart-y-axis,
1045
+ :is(.chart-dot, .chart-line).has-negative-y .chart-y-axis,
871
1046
  .chart-scatter.has-negative-y .chart-y-axis {
872
1047
  padding-bottom: 0.5rem;
873
1048
  }
874
1049
 
875
1050
  /* Zero axis lines - use dot-field for proper alignment */
876
- .chart-dot.has-negative-y .dot-field::after {
1051
+ :is(.chart-dot, .chart-line).has-negative-y .dot-field::after {
877
1052
  content: '';
878
1053
  position: absolute;
879
1054
  left: 0;
@@ -918,20 +1093,20 @@
918
1093
  ========================================================================== */
919
1094
 
920
1095
  /* Y-axis needs relative positioning for absolute label */
921
- .chart-dot.has-negative-y .chart-y-axis,
1096
+ :is(.chart-dot, .chart-line).has-negative-y .chart-y-axis,
922
1097
  .chart-scatter.has-negative-y .chart-y-axis {
923
1098
  position: relative;
924
1099
  }
925
1100
 
926
1101
  /* Position middle Y-axis label at zero */
927
- .chart-dot.has-negative-y .chart-y-axis .axis-label:nth-child(2),
1102
+ :is(.chart-dot, .chart-line).has-negative-y .chart-y-axis .axis-label:nth-child(2),
928
1103
  .chart-scatter.has-negative-y .chart-y-axis .axis-label:nth-child(2) {
929
1104
  position: absolute;
930
1105
  right: 0;
931
1106
  transform: translateY(50%);
932
1107
  }
933
1108
 
934
- .chart-dot.has-negative-y .chart-y-axis .axis-label:nth-child(2) {
1109
+ :is(.chart-dot, .chart-line).has-negative-y .chart-y-axis .axis-label:nth-child(2) {
935
1110
  bottom: calc(0.5rem + var(--zero-position, 50%) * 11 / 12);
936
1111
  }
937
1112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
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",
@@ -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 } = config;
18
+ const { title, subtitle, data, max, min, legend, 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 -->`;
@@ -47,7 +47,8 @@ export function renderDot(config) {
47
47
  const negativeClass = hasNegativeY ? ' has-negative-y' : '';
48
48
  const idClass = id ? ` chart-${id}` : '';
49
49
  const rotateClass = rotateLabels ? ' rotate-labels' : '';
50
- let html = `<figure class="chart chart-dot${animateClass}${negativeClass}${idClass}${rotateClass}">`;
50
+ const dotsClass = !showDots ? ' no-dots' : '';
51
+ let html = `<figure class="chart chart-${chartType}${animateClass}${negativeClass}${idClass}${rotateClass}${dotsClass}">`;
51
52
 
52
53
  if (title) {
53
54
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -92,31 +93,56 @@ export function renderDot(config) {
92
93
  html += `<div class="dot-chart" style="${styleVars.join('; ')}">`;
93
94
  html += `<div class="dot-field">`;
94
95
 
95
- // Each row becomes a column with dots for each series
96
- data.forEach((row, colIndex) => {
97
- const label = row[labelKey] ?? '';
98
-
99
- html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
100
-
96
+ // CSS line segments connecting dots (rendered before dot-cols so they stack behind)
97
+ if (connectDots && data.length > 1) {
98
+ let segIndex = 0;
101
99
  seriesKeys.forEach((key, i) => {
102
- const val = row[key];
103
- const value = typeof val === 'number' ? val : parseFloat(val) || 0;
104
- const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
105
100
  const colorClass = `chart-color-${i + 1}`;
106
101
  const seriesClass = `chart-series-${slugify(key)}`;
107
- const tooltipLabel = legendLabels[i] ?? key;
108
-
109
- html += `<div class="dot ${colorClass} ${seriesClass}" `;
110
- html += `style="--value: ${yPct.toFixed(2)}%" `;
111
- html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, format) || value}"`;
112
- html += `></div>`;
102
+ for (let colIndex = 0; colIndex < data.length - 1; colIndex++) {
103
+ const val1 = data[colIndex][key];
104
+ const val2 = data[colIndex + 1][key];
105
+ const v1 = typeof val1 === 'number' ? val1 : parseFloat(val1) || 0;
106
+ const v2 = typeof val2 === 'number' ? val2 : parseFloat(val2) || 0;
107
+ const y1 = range > 0 ? ((v1 - minValue) / range) * 100 : 0;
108
+ const y2 = range > 0 ? ((v2 - minValue) / range) * 100 : 0;
109
+ const x1 = ((colIndex + 0.5) / data.length) * 100;
110
+ const x2 = ((colIndex + 1.5) / data.length) * 100;
111
+ html += `<div class="chart-line-segment ${colorClass} ${seriesClass}" `;
112
+ html += `style="--x1: ${x1.toFixed(2)}; --y1: ${y1.toFixed(2)}; --x2: ${x2.toFixed(2)}; --y2: ${y2.toFixed(2)}; --seg-index: ${segIndex}">`;
113
+ html += `</div>`;
114
+ segIndex++;
115
+ }
113
116
  });
117
+ }
114
118
 
115
- html += `</div>`;
116
- });
119
+ // Each row becomes a column with dots for each series
120
+ if (showDots) {
121
+ data.forEach((row, colIndex) => {
122
+ const label = row[labelKey] ?? '';
123
+
124
+ html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
125
+
126
+ seriesKeys.forEach((key, i) => {
127
+ const val = row[key];
128
+ const value = typeof val === 'number' ? val : parseFloat(val) || 0;
129
+ const yPct = range > 0 ? ((value - minValue) / range) * 100 : 0;
130
+ const colorClass = `chart-color-${i + 1}`;
131
+ const seriesClass = `chart-series-${slugify(key)}`;
132
+ const tooltipLabel = legendLabels[i] ?? key;
133
+
134
+ html += `<div class="dot ${colorClass} ${seriesClass}" `;
135
+ html += `style="--value: ${yPct.toFixed(2)}%" `;
136
+ html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, format) || value}"`;
137
+ html += `></div>`;
138
+ });
139
+
140
+ html += `</div>`;
141
+ });
142
+ }
117
143
 
118
- html += `</div>`;
119
- html += `</div>`;
144
+ html += `</div>`; // close dot-field
145
+ html += `</div>`; // close dot-chart
120
146
 
121
147
  // X-axis labels
122
148
  html += `<div class="dot-labels">`;
@@ -3,13 +3,17 @@ import { renderStackedColumn } from './stacked-column.js';
3
3
  import { renderDonut } from './donut.js';
4
4
  import { renderDot } from './dot.js';
5
5
  import { renderScatter } from './scatter.js';
6
+ import { renderSankey } from './sankey.js';
7
+ import { renderLine } from './line.js';
6
8
 
7
9
  export const renderers = {
8
10
  'stacked-bar': renderStackedBar,
9
11
  'stacked-column': renderStackedColumn,
10
12
  'donut': renderDonut,
11
13
  'dot': renderDot,
12
- 'scatter': renderScatter
14
+ 'scatter': renderScatter,
15
+ 'sankey': renderSankey,
16
+ 'line': renderLine
13
17
  };
14
18
 
15
- export { renderStackedBar, renderStackedColumn, renderDonut, renderDot, renderScatter };
19
+ export { renderStackedBar, renderStackedColumn, renderDonut, renderDot, renderScatter, renderSankey, renderLine };
@@ -0,0 +1,5 @@
1
+ import { renderDot } from './dot.js';
2
+
3
+ export function renderLine(config) {
4
+ return renderDot({ ...config, connectDots: true, chartType: 'line' });
5
+ }
@@ -0,0 +1,528 @@
1
+ import { slugify, escapeHtml, renderDownloadLink } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
3
+
4
+ /**
5
+ * Render a Sankey diagram
6
+ * @param {Object} config - Chart configuration
7
+ * @param {string} config.title - Chart title
8
+ * @param {string} [config.subtitle] - Chart subtitle
9
+ * @param {Object[]} config.data - Chart data (source, target, value columns)
10
+ * @param {boolean} [config.legend] - Show legend for nodes
11
+ * @param {boolean} [config.animate] - Enable animations
12
+ * @param {number} [config.nodeWidth] - Width of node bars in pixels (default: 20)
13
+ * @param {number} [config.nodePadding] - Vertical gap between nodes in pixels (default: 10)
14
+ * @param {boolean} [config.endLabelsOutside] - Position last level labels outside/right (default: false)
15
+ * @param {boolean} [config.proportional] - Force proportional node heights for data integrity (default: false)
16
+ * @returns {string} - HTML string
17
+ */
18
+ export function renderSankey(config) {
19
+ const { title, subtitle, data, legend, animate, format, id, downloadData, downloadDataUrl, nodeWidth = 20, nodePadding = 10, endLabelsOutside = false, proportional = false } = config;
20
+
21
+ if (!data || data.length === 0) {
22
+ return `<!-- Sankey chart: no data provided -->`;
23
+ }
24
+
25
+ const animateClass = animate ? ' chart-animate' : '';
26
+
27
+ // Get column keys positionally
28
+ const keys = Object.keys(data[0]);
29
+ const sourceKey = keys[0]; // First column: source
30
+ const targetKey = keys[1]; // Second column: target
31
+ const valueKey = keys[2]; // Third column: value
32
+
33
+ // Parse edges and build node set
34
+ const edges = [];
35
+ const nodeSet = new Set();
36
+ const nodeInFlow = new Map(); // Total flow into each node
37
+ const nodeOutFlow = new Map(); // Total flow out of each node
38
+
39
+ data.forEach((row, rowIndex) => {
40
+ const source = String(row[sourceKey] ?? '').trim();
41
+ const target = String(row[targetKey] ?? '').trim();
42
+ const value = typeof row[valueKey] === 'number' ? row[valueKey] : parseFloat(row[valueKey]) || 0;
43
+
44
+ if (source && target && value > 0) {
45
+ // Check for self-loops
46
+ if (source === target) {
47
+ throw new Error(`Sankey chart error: Self-loop detected at row ${rowIndex + 2} - "${source}" cannot flow to itself`);
48
+ }
49
+ edges.push({ source, target, value });
50
+ nodeSet.add(source);
51
+ nodeSet.add(target);
52
+ nodeOutFlow.set(source, (nodeOutFlow.get(source) || 0) + value);
53
+ nodeInFlow.set(target, (nodeInFlow.get(target) || 0) + value);
54
+ }
55
+ });
56
+
57
+ if (edges.length === 0) {
58
+ return `<!-- Sankey chart: no valid edges -->`;
59
+ }
60
+
61
+ // Aggregate duplicate edges (same source -> target)
62
+ const edgeMap = new Map();
63
+ edges.forEach(({ source, target, value }) => {
64
+ const key = `${source}::${target}`;
65
+ edgeMap.set(key, (edgeMap.get(key) || 0) + value);
66
+ });
67
+
68
+ const aggregatedEdges = Array.from(edgeMap.entries()).map(([key, value]) => {
69
+ const [source, target] = key.split('::');
70
+ return { source, target, value };
71
+ });
72
+
73
+ // Count flows per node (for minimum height calculation)
74
+ const nodeOutFlowCount = new Map();
75
+ const nodeInFlowCount = new Map();
76
+ aggregatedEdges.forEach(({ source, target }) => {
77
+ nodeOutFlowCount.set(source, (nodeOutFlowCount.get(source) || 0) + 1);
78
+ nodeInFlowCount.set(target, (nodeInFlowCount.get(target) || 0) + 1);
79
+ });
80
+
81
+ // Determine node levels via topological sort (BFS from sources)
82
+ const nodeLevel = new Map();
83
+ const nodes = Array.from(nodeSet);
84
+
85
+ // Find source nodes (nodes with no incoming edges)
86
+ const sourceNodes = nodes.filter(n => !nodeInFlow.has(n) || nodeInFlow.get(n) === 0);
87
+
88
+ // Initialize source nodes at level 0
89
+ const queue = sourceNodes.map(n => ({ node: n, level: 0 }));
90
+ sourceNodes.forEach(n => nodeLevel.set(n, 0));
91
+
92
+ // BFS to assign levels
93
+ while (queue.length > 0) {
94
+ const { node, level } = queue.shift();
95
+
96
+ // Find edges from this node
97
+ aggregatedEdges.forEach(edge => {
98
+ if (edge.source === node) {
99
+ const targetLevel = level + 1;
100
+ const currentLevel = nodeLevel.get(edge.target);
101
+
102
+ // Use the maximum level (longest path to this node)
103
+ if (currentLevel === undefined || targetLevel > currentLevel) {
104
+ nodeLevel.set(edge.target, targetLevel);
105
+ queue.push({ node: edge.target, level: targetLevel });
106
+ }
107
+ }
108
+ });
109
+ }
110
+
111
+ // Handle any unassigned nodes (cycles or disconnected)
112
+ nodes.forEach(n => {
113
+ if (!nodeLevel.has(n)) {
114
+ nodeLevel.set(n, 0);
115
+ }
116
+ });
117
+
118
+ // Group nodes by level
119
+ const levelCount = Math.max(...nodeLevel.values()) + 1;
120
+ const levels = Array.from({ length: levelCount }, () => []);
121
+ nodes.forEach(n => {
122
+ levels[nodeLevel.get(n)].push(n);
123
+ });
124
+
125
+ // Calculate max label width per level (character count × 0.5rem + padding)
126
+ const maxLabelWidthPerLevel = levels.map(levelNodes => {
127
+ const maxChars = Math.max(...levelNodes.map(node => node.length));
128
+ return maxChars * 0.5 + 1; // 0.5rem per char + 1rem padding
129
+ });
130
+
131
+ // Calculate minimum flow column width
132
+ // For each flow column between levels i and i+1:
133
+ // - Level i labels extend right into the flow column
134
+ // - Level i+1 labels extend left into the flow column (only if it's the last level AND !endLabelsOutside)
135
+ let minFlowWidth = 0;
136
+ for (let i = 0; i < levelCount - 1; i++) {
137
+ let width = maxLabelWidthPerLevel[i]; // Labels from level i (pointing right)
138
+ const isNextLevelLast = (i + 1 === levelCount - 1);
139
+ if (isNextLevelLast && !endLabelsOutside) {
140
+ width += maxLabelWidthPerLevel[i + 1]; // Labels from last level pointing left
141
+ }
142
+ if (width > minFlowWidth) {
143
+ minFlowWidth = width;
144
+ }
145
+ }
146
+
147
+ // Calculate node throughput (max of in/out flow) for sizing
148
+ const nodeThroughput = new Map();
149
+ nodes.forEach(n => {
150
+ const inFlow = nodeInFlow.get(n) || 0;
151
+ const outFlow = nodeOutFlow.get(n) || 0;
152
+ nodeThroughput.set(n, Math.max(inFlow, outFlow));
153
+ });
154
+
155
+ // Calculate total throughput per level for scaling
156
+ const levelThroughput = levels.map(levelNodes =>
157
+ levelNodes.reduce((sum, n) => sum + nodeThroughput.get(n), 0)
158
+ );
159
+ const maxLevelThroughput = Math.max(...levelThroughput);
160
+
161
+ // Calculate vertical positions for nodes within each level
162
+ // Positions are percentages (0-100)
163
+ const nodePosition = new Map(); // { top: %, height: % }
164
+ const paddingPct = (nodePadding / 400) * 100; // Convert px to approximate %
165
+ const minNodeHeight = 2; // Minimum node height in percentage points
166
+ const minGapHeight = 4; // Minimum gap to prevent label overlap (%)
167
+ const minFlowHeightBase = 0.4; // Base minimum flow height (before scaling)
168
+
169
+ // Track maximum height needed across all levels
170
+ let maxLevelHeight = 100;
171
+
172
+ // When proportional mode is on, scale the entire chart so the smallest node
173
+ // is at least ~1px visible, preserving true proportions
174
+ // 0.4% of base 16rem min-height ≈ 1px
175
+ const proportionalMinHeight = 0.4;
176
+ let proportionalScale = 1;
177
+ if (proportional) {
178
+ const smallestHeight = Math.min(...nodes.map(n => (nodeThroughput.get(n) / maxLevelThroughput) * 100));
179
+ if (smallestHeight > 0 && smallestHeight < proportionalMinHeight) {
180
+ proportionalScale = proportionalMinHeight / smallestHeight;
181
+ }
182
+ }
183
+
184
+ levels.forEach((levelNodes, levelIndex) => {
185
+ // Calculate proportional heights based on full 100% (not reduced by padding)
186
+ const nodeHeights = levelNodes.map(node => {
187
+ const throughput = nodeThroughput.get(node);
188
+ return {
189
+ node,
190
+ height: (throughput / maxLevelThroughput) * 100 * proportionalScale
191
+ };
192
+ });
193
+
194
+ let effectivePadding = paddingPct;
195
+
196
+ if (!proportional) {
197
+ // Count nodes that will hit minimum height to determine if we need larger gaps
198
+ const smallNodeCount = nodeHeights.filter(n => n.height < minNodeHeight).length;
199
+
200
+ // If more than half the nodes are small, use minimum gap to prevent label overlap
201
+ if (smallNodeCount > levelNodes.length / 2) {
202
+ effectivePadding = Math.max(paddingPct, minGapHeight);
203
+ }
204
+
205
+ // Enforce minimum heights (container will scale to fit)
206
+ // Node must be tall enough for: 1) visibility, 2) all connected flows
207
+ nodeHeights.forEach(n => {
208
+ const outFlows = nodeOutFlowCount.get(n.node) || 0;
209
+ const inFlows = nodeInFlowCount.get(n.node) || 0;
210
+ const flowBasedMin = Math.max(outFlows, inFlows) * minFlowHeightBase;
211
+ const nodeMin = Math.max(minNodeHeight, flowBasedMin);
212
+ if (n.height < nodeMin) {
213
+ n.height = nodeMin;
214
+ }
215
+ });
216
+ }
217
+
218
+ // Assign positions (padding adds to total, may exceed 100%)
219
+ let currentTop = 0;
220
+ nodeHeights.forEach(({ node, height }) => {
221
+ nodePosition.set(node, {
222
+ top: currentTop,
223
+ height: height,
224
+ level: levelIndex
225
+ });
226
+ currentTop += height + effectivePadding;
227
+ });
228
+
229
+ // Track the actual height needed (last node bottom = currentTop - last padding)
230
+ const levelHeight = currentTop - effectivePadding;
231
+ if (levelHeight > maxLevelHeight) {
232
+ maxLevelHeight = levelHeight;
233
+ }
234
+ });
235
+
236
+ // Calculate height scale factor if content exceeds 100%
237
+ const heightScale = maxLevelHeight / 100;
238
+
239
+ // Normalize positions to 0-100 range; container grows via --height-scale CSS variable
240
+ if (heightScale > 1) {
241
+ nodePosition.forEach((pos, node) => {
242
+ pos.top = pos.top / heightScale;
243
+ pos.height = pos.height / heightScale;
244
+ });
245
+ }
246
+
247
+ // Center each level vertically based on its own content height
248
+ levels.forEach((levelNodes, levelIndex) => {
249
+ if (levelNodes.length === 0) return;
250
+
251
+ // Find the bottom of the last node in this level
252
+ let maxBottom = 0;
253
+ levelNodes.forEach(node => {
254
+ const pos = nodePosition.get(node);
255
+ const bottom = pos.top + pos.height;
256
+ if (bottom > maxBottom) maxBottom = bottom;
257
+ });
258
+
259
+ // Center this level
260
+ const centerOffset = (100 - maxBottom) / 2;
261
+ if (centerOffset > 0) {
262
+ levelNodes.forEach(node => {
263
+ const pos = nodePosition.get(node);
264
+ pos.top = pos.top + centerOffset;
265
+ });
266
+ }
267
+ });
268
+
269
+ // Sort edges by source level, then target level for consistent rendering
270
+ aggregatedEdges.sort((a, b) => {
271
+ const aSourceLevel = nodeLevel.get(a.source);
272
+ const bSourceLevel = nodeLevel.get(b.source);
273
+ if (aSourceLevel !== bSourceLevel) return aSourceLevel - bSourceLevel;
274
+ const aTargetLevel = nodeLevel.get(a.target);
275
+ const bTargetLevel = nodeLevel.get(b.target);
276
+ return aTargetLevel - bTargetLevel;
277
+ });
278
+
279
+ // Calculate flow heights proportional to each end's node height.
280
+ // Flows taper between source and target, naturally filling both nodes.
281
+ const flowData = aggregatedEdges.map((edge, index) => {
282
+ const sourcePos = nodePosition.get(edge.source);
283
+ const targetPos = nodePosition.get(edge.target);
284
+ const sourceThroughput = nodeThroughput.get(edge.source);
285
+ const targetThroughput = nodeThroughput.get(edge.target);
286
+
287
+ return {
288
+ ...edge,
289
+ fromLevel: sourcePos.level,
290
+ toLevel: targetPos.level,
291
+ fromHeight: (edge.value / sourceThroughput) * sourcePos.height,
292
+ toHeight: (edge.value / targetThroughput) * targetPos.height,
293
+ index
294
+ };
295
+ });
296
+
297
+ // Calculate flow positions within each node
298
+ const nodeOutOffset = new Map();
299
+ const nodeInOffset = new Map();
300
+ nodes.forEach(n => {
301
+ nodeOutOffset.set(n, 0);
302
+ nodeInOffset.set(n, 0);
303
+ });
304
+
305
+ const flows = flowData.map(f => {
306
+ const sourcePos = nodePosition.get(f.source);
307
+ const targetPos = nodePosition.get(f.target);
308
+
309
+ const sourceOffset = nodeOutOffset.get(f.source);
310
+ const targetOffset = nodeInOffset.get(f.target);
311
+
312
+ nodeOutOffset.set(f.source, sourceOffset + f.fromHeight);
313
+ nodeInOffset.set(f.target, targetOffset + f.toHeight);
314
+
315
+ return {
316
+ ...f,
317
+ fromTop: sourcePos.top + sourceOffset,
318
+ toTop: targetPos.top + targetOffset
319
+ };
320
+ });
321
+
322
+ // Enforce minimum flow heights by redistributing space
323
+ // Small flows get bumped up; space is borrowed from larger flows
324
+ // Minimum flow height in percentage points (scale down if container is taller)
325
+ const minFlowHeight = minFlowHeightBase / (heightScale > 1 ? heightScale : 1);
326
+
327
+ // Helper to redistribute heights for one side of flows at a node
328
+ function enforceMinHeights(flowsAtNode, heightKey, topKey, nodeTop, nodeHeight) {
329
+ if (flowsAtNode.length === 0) return;
330
+
331
+ // Identify small and large flows
332
+ const small = flowsAtNode.filter(f => f[heightKey] < minFlowHeight);
333
+ const large = flowsAtNode.filter(f => f[heightKey] >= minFlowHeight);
334
+
335
+ if (small.length === 0) return; // Nothing to adjust
336
+
337
+ // Calculate space needed
338
+ const spaceNeeded = small.reduce((sum, f) => sum + (minFlowHeight - f[heightKey]), 0);
339
+ const largeTotal = large.reduce((sum, f) => sum + f[heightKey], 0);
340
+
341
+ if (largeTotal <= 0) {
342
+ // All flows are small; just set them all to minimum
343
+ small.forEach(f => { f[heightKey] = minFlowHeight; });
344
+ } else {
345
+ // Borrow space proportionally from large flows
346
+ const borrowRatio = Math.min(1, spaceNeeded / largeTotal);
347
+ large.forEach(f => {
348
+ f[heightKey] = f[heightKey] * (1 - borrowRatio);
349
+ });
350
+ small.forEach(f => {
351
+ f[heightKey] = minFlowHeight;
352
+ });
353
+ }
354
+
355
+ // Recalculate top positions
356
+ let currentTop = nodeTop;
357
+ flowsAtNode.forEach(f => {
358
+ f[topKey] = currentTop;
359
+ currentTop += f[heightKey];
360
+ });
361
+ }
362
+
363
+ // Group flows by source node and adjust fromHeight/fromTop
364
+ const flowsBySource = new Map();
365
+ flows.forEach(f => {
366
+ if (!flowsBySource.has(f.source)) flowsBySource.set(f.source, []);
367
+ flowsBySource.get(f.source).push(f);
368
+ });
369
+ flowsBySource.forEach((nodeFlows, nodeName) => {
370
+ const pos = nodePosition.get(nodeName);
371
+ enforceMinHeights(nodeFlows, 'fromHeight', 'fromTop', pos.top, pos.height);
372
+ });
373
+
374
+ // Group flows by target node and adjust toHeight/toTop
375
+ const flowsByTarget = new Map();
376
+ flows.forEach(f => {
377
+ if (!flowsByTarget.has(f.target)) flowsByTarget.set(f.target, []);
378
+ flowsByTarget.get(f.target).push(f);
379
+ });
380
+ flowsByTarget.forEach((nodeFlows, nodeName) => {
381
+ const pos = nodePosition.get(nodeName);
382
+ enforceMinHeights(nodeFlows, 'toHeight', 'toTop', pos.top, pos.height);
383
+ });
384
+
385
+ // Assign colors to nodes
386
+ const nodeColors = new Map();
387
+ nodes.forEach((node, i) => {
388
+ nodeColors.set(node, (i % 12) + 1);
389
+ });
390
+
391
+ // Build HTML
392
+ // Generate grid columns: alternating node-width and minmax(min-flow-width, 1fr)
393
+ // For n levels: node-width (minmax node-width) * (n-1)
394
+ const gridColumns = Array(levelCount).fill('var(--sankey-node-width)').join(' minmax(var(--min-flow-width), 1fr) ');
395
+
396
+ // Calculate end label width if labels are outside
397
+ let endLabelWidthStyle = '';
398
+ if (endLabelsOutside && levels.length > 0) {
399
+ const lastLevel = levels[levels.length - 1];
400
+ const maxLabelLength = Math.max(...lastLevel.map(node => node.length));
401
+ // Approximate width: 0.5ch per character at 0.75rem + padding
402
+ const labelWidth = maxLabelLength * 0.5 + 1; // in rem
403
+ endLabelWidthStyle = ` --end-label-width: ${labelWidth.toFixed(1)}rem;`;
404
+ }
405
+
406
+ const idClass = id ? ` chart-${id}` : '';
407
+ const endLabelsOutsideClass = endLabelsOutside ? ' chart-sankey-end-labels-outside' : '';
408
+ let html = `<figure class="chart chart-sankey${animateClass}${idClass}${endLabelsOutsideClass}" style="--node-width: ${nodeWidth}px; --level-count: ${levelCount}; --grid-columns: ${gridColumns}; --min-flow-width: ${minFlowWidth.toFixed(1)}rem; --height-scale: ${heightScale.toFixed(2)};${endLabelWidthStyle}">`;
409
+
410
+ if (title) {
411
+ html += `<figcaption class="chart-title">${escapeHtml(title)}`;
412
+ if (subtitle) {
413
+ html += `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>`;
414
+ }
415
+ html += `</figcaption>`;
416
+ }
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
+ // Build neighbor map for node hover highlighting
435
+ const nodeNeighbors = new Map();
436
+ nodes.forEach(n => nodeNeighbors.set(n, new Set()));
437
+ aggregatedEdges.forEach(({ source, target }) => {
438
+ nodeNeighbors.get(source).add(target);
439
+ nodeNeighbors.get(target).add(source);
440
+ });
441
+
442
+ // Generate per-node hover rules: brighten connected flows and nodes, dim the rest
443
+ html += `<style>`;
444
+ nodes.forEach(node => {
445
+ const slug = slugify(node);
446
+ const prefix = `.chart-sankey-container:has(.chart-series-${slug}:hover)`;
447
+ // Brighten connected flows
448
+ html += `${prefix} .chart-flow-${slug} path{opacity:0.8}`;
449
+ // Brighten hovered node + its neighbors
450
+ html += `${prefix} .chart-series-${slug}{opacity:1}`;
451
+ nodeNeighbors.get(node).forEach(neighbor => {
452
+ html += `${prefix} .chart-series-${slugify(neighbor)}{opacity:1}`;
453
+ });
454
+ });
455
+ html += `</style>`;
456
+
457
+ html += `<div class="chart-sankey-container">`;
458
+
459
+ // Flows (rendered as SVG paths with bezier curves)
460
+ const delayStep = flows.length > 1 ? Math.min(0.1, 1 / (flows.length - 1)) : 0;
461
+ flows.forEach((flow, i) => {
462
+ const sourceColor = nodeColors.get(flow.source);
463
+ const targetColor = nodeColors.get(flow.target);
464
+ const tooltipText = `${flow.source} → ${flow.target}: ${formatNumber(flow.value, format) || flow.value}`;
465
+
466
+ // Flow spans from after source node column to target node column
467
+ const colStart = flow.fromLevel * 2 + 2;
468
+ const colEnd = flow.toLevel * 2 + 1;
469
+
470
+ // SVG path coordinates (0-100 viewBox)
471
+ const y1 = flow.fromTop;
472
+ const y2 = flow.toTop;
473
+ const fh = flow.fromHeight;
474
+ const th = flow.toHeight;
475
+
476
+ // Bezier control points at 40% and 60% for smooth S-curve
477
+ const cx1 = 40;
478
+ const cx2 = 60;
479
+
480
+ // Path: top edge (left to right with curve), then bottom edge (right to left with curve)
481
+ // Flow tapers between fromHeight at source and toHeight at target
482
+ // Extend slightly past 0/100 to overlap with node columns and prevent subpixel gaps
483
+ const x0 = -2;
484
+ const x1end = 102;
485
+ 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`;
486
+
487
+ const sourceSlug = slugify(flow.source);
488
+ const targetSlug = slugify(flow.target);
489
+ html += `<svg class="chart-sankey-flow chart-flow-${sourceSlug} chart-flow-${targetSlug}" viewBox="0 0 100 100" preserveAspectRatio="none" `;
490
+ html += `style="grid-column: ${colStart} / ${colEnd}; --from-level: ${flow.fromLevel}; --flow-index: ${i}; --delay-step: ${delayStep.toFixed(3)}s">`;
491
+ html += `<defs><linearGradient id="sankey-grad-${id || 'default'}-${i}">`;
492
+ html += `<stop offset="0%" style="stop-color: var(--chart-color-${sourceColor})" />`;
493
+ html += `<stop offset="100%" style="stop-color: var(--chart-color-${targetColor})" />`;
494
+ html += `</linearGradient></defs>`;
495
+ html += `<path d="${pathD}" fill="url(#sankey-grad-${id || 'default'}-${i})"><title>${escapeHtml(tooltipText)}</title></path>`;
496
+ html += `</svg>`;
497
+ });
498
+
499
+ // Nodes grouped by level
500
+ levels.forEach((levelNodes, levelIndex) => {
501
+ // Level i goes in column (2*i + 1) using 1-based indexing
502
+ const isFirst = levelIndex === 0;
503
+ const isLast = levelIndex === levels.length - 1;
504
+ const levelClass = isFirst ? ' chart-sankey-level-first' : isLast ? ' chart-sankey-level-last' : '';
505
+ html += `<div class="chart-sankey-level${levelClass}" style="grid-column: ${levelIndex * 2 + 1}">`;
506
+ levelNodes.forEach(node => {
507
+ const pos = nodePosition.get(node);
508
+ const colorIndex = nodeColors.get(node);
509
+ const colorClass = `chart-color-${colorIndex}`;
510
+ const seriesClass = `chart-series-${slugify(node)}`;
511
+ const throughput = nodeThroughput.get(node);
512
+ const tooltipText = `${node}: ${formatNumber(throughput, format) || throughput}`;
513
+
514
+ html += `<div class="chart-sankey-node ${colorClass} ${seriesClass}" `;
515
+ html += `style="--top: ${pos.top.toFixed(2)}%; --height: ${pos.height.toFixed(2)}%" `;
516
+ html += `title="${escapeHtml(tooltipText)}">`;
517
+ html += `<span class="chart-sankey-node-label">${escapeHtml(node)}</span>`;
518
+ html += `</div>`;
519
+ });
520
+ html += `</div>`;
521
+ });
522
+
523
+ html += `</div>`;
524
+ html += renderDownloadLink(downloadDataUrl, downloadData);
525
+ html += `</figure>`;
526
+
527
+ return html;
528
+ }