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 +196 -21
- package/package.json +1 -1
- package/src/renderers/dot.js +47 -21
- package/src/renderers/index.js +6 -2
- package/src/renderers/line.js +5 -0
- package/src/renderers/sankey.js +528 -0
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:
|
|
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
package/src/renderers/dot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
96
|
-
data.
|
|
97
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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">`;
|
package/src/renderers/index.js
CHANGED
|
@@ -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,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
|
+
}
|