Files
radzen-blazor/Radzen.Blazor/SankeyLayout.cs

482 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Radzen.Blazor.Rendering;
namespace Radzen.Blazor
{
/// <summary>
/// Computes the layout for a Sankey diagram.
/// </summary>
internal class SankeyLayout
{
public double Width { get; set; }
public double Height { get; set; }
public double NodeWidth { get; set; } = 24;
public double NodePadding { get; set; } = 8;
public SankeyAlignment NodeAlignment { get; set; } = SankeyAlignment.Justify;
public Func<SankeyNode, SankeyNode, int>? NodeSort { get; set; }
public Func<SankeyLink, SankeyLink, int>? LinkSort { get; set; }
private const int MaxIterations = 32;
public (IList<ComputedSankeyNode>, IList<ComputedSankeyLink>) Compute(IEnumerable<SankeyNode> nodes, IEnumerable<SankeyLink> links)
{
// Create computed nodes
var computedNodes = nodes.Select(n => new ComputedSankeyNode
{
Id = n.Id,
Label = n.Label,
Value = n.Value,
Width = NodeWidth,
Y = 0, // Initialize Y to 0
Height = 0 // Initialize Height
}).ToList();
// Create node lookup
var nodeById = computedNodes.ToDictionary(n => n.Id ?? throw new InvalidOperationException("Sankey node Id cannot be null."));
// Create computed links
var computedLinks = links.Select(l => new ComputedSankeyLink
{
Source = l.Source,
Target = l.Target,
Value = l.Value,
SourceNode = nodeById[l.Source ?? throw new InvalidOperationException("Sankey link Source cannot be null.")],
TargetNode = nodeById[l.Target ?? throw new InvalidOperationException("Sankey link Target cannot be null.")]
}).ToList();
// Connect nodes and links
foreach (var link in computedLinks)
{
if (link.SourceNode == null || link.TargetNode == null)
continue;
link.SourceNode.SourceLinks.Add(link);
link.TargetNode.TargetLinks.Add(link);
}
// Compute node values
ComputeNodeValues(computedNodes);
// Compute node layers
ComputeNodeLayers(computedNodes);
// Compute node breadths
ComputeNodeBreadths(computedNodes);
// Compute node depths
ComputeNodeDepths(computedNodes);
// Compute link paths
ComputeLinkPaths(computedLinks);
return (computedNodes, computedLinks);
}
private void ComputeNodeValues(List<ComputedSankeyNode> nodes)
{
foreach (var node in nodes)
{
// Calculate and store the actual sums
var incomingSum = node.TargetLinks.Sum(l => l.Value);
var outgoingSum = node.SourceLinks.Sum(l => l.Value);
if (node.Value.HasValue)
{
node.ComputedValue = node.Value.Value;
}
else
{
node.ComputedValue = Math.Max(incomingSum, outgoingSum);
}
}
}
private void ComputeNodeLayers(List<ComputedSankeyNode> nodes)
{
// Find nodes with no incoming links
var sources = nodes.Where(n => n.TargetLinks.Count == 0).ToList();
var visited = new HashSet<ComputedSankeyNode>();
var layers = new List<List<ComputedSankeyNode>>();
// Breadth-first traversal
var queue = new Queue<ComputedSankeyNode>(sources);
foreach (var source in sources)
{
source.Layer = 0;
visited.Add(source);
}
while (queue.Count > 0)
{
var node = queue.Dequeue();
foreach (var link in node.SourceLinks)
{
var target = link.TargetNode;
if (target != null && !visited.Contains(target))
{
target.Layer = node.Layer + 1;
visited.Add(target);
queue.Enqueue(target);
}
else
{
if (target != null)
target.Layer = Math.Max(target.Layer, node.Layer + 1);
}
}
}
// Handle cycles and disconnected nodes
foreach (var node in nodes)
{
if (!visited.Contains(node))
{
node.Layer = 0;
}
}
}
private void ComputeNodeBreadths(List<ComputedSankeyNode> nodes)
{
var maxLayer = nodes.Max(n => n.Layer);
// Group nodes by layer
var nodesByLayer = nodes.GroupBy(n => n.Layer).OrderBy(g => g.Key).ToList();
foreach (var layer in nodesByLayer)
{
var layerNodes = layer.ToList();
// Sort nodes in layer
if (NodeSort != null)
{
layerNodes.Sort((a, b) => NodeSort(a, b));
}
// Set X positions
foreach (var node in layerNodes)
{
if (maxLayer == 0)
{
// Single layer - center it
node.X = (Width - NodeWidth) / 2;
}
else
{
var layerSpacing = (Width - NodeWidth) / maxLayer;
switch (NodeAlignment)
{
case SankeyAlignment.Left:
// Align nodes to the left with compact spacing
var leftSpacing = Math.Min(layerSpacing, (Width * 0.7) / maxLayer);
node.X = node.Layer * leftSpacing;
break;
case SankeyAlignment.Right:
// Align nodes to the right with compact spacing
var rightSpacing = Math.Min(layerSpacing, (Width * 0.7) / maxLayer);
var totalRightWidth = maxLayer * rightSpacing;
node.X = Width - NodeWidth - totalRightWidth + (node.Layer * rightSpacing);
break;
case SankeyAlignment.Center:
// Center with proportional spacing
var centerSpacing = layerSpacing * 0.8; // Slightly compressed
var totalWidth = maxLayer * centerSpacing;
var startX = (Width - totalWidth - NodeWidth) / 2;
node.X = startX + node.Layer * centerSpacing;
break;
case SankeyAlignment.Justify:
default:
// Distribute evenly across full width
node.X = node.Layer * layerSpacing;
break;
}
}
}
}
}
private void ComputeNodeDepths(List<ComputedSankeyNode> nodes)
{
// Group nodes by layer
var nodesByLayer = nodes.GroupBy(n => n.Layer).OrderBy(g => g.Key).ToList();
// Initialize node depths
foreach (var layer in nodesByLayer)
{
var layerNodes = layer.ToList();
InitializeNodeDepths(layerNodes);
}
// Resolve collisions
ResolveCollisions(nodesByLayer);
// Sort links
foreach (var node in nodes)
{
if (LinkSort != null)
{
node.SourceLinks.Sort((a, b) => LinkSort(a, b));
node.TargetLinks.Sort((a, b) => LinkSort(a, b));
}
}
// Assign link positions
AssignLinkDepths(nodes);
}
private void InitializeNodeDepths(List<ComputedSankeyNode> nodes)
{
if (nodes.Count == 0) return;
var totalValue = nodes.Sum(n => n.ComputedValue);
var totalPadding = Math.Max(0, (nodes.Count - 1) * NodePadding);
var availableHeight = Math.Max(1, Height - totalPadding);
if (totalValue > 0)
{
var valueScale = availableHeight / totalValue;
var y = 0.0;
foreach (var node in nodes)
{
// Ensure minimum height of 3px for visibility
node.Height = Math.Max(3, node.ComputedValue * valueScale);
node.Y = y;
y += node.Height + NodePadding;
}
}
else
{
// Distribute evenly if no values
var nodeHeight = Math.Max(1, availableHeight / nodes.Count);
for (int i = 0; i < nodes.Count; i++)
{
nodes[i].Height = nodeHeight;
nodes[i].Y = i * (nodeHeight + NodePadding);
}
}
}
private void ResolveCollisions(List<IGrouping<int, ComputedSankeyNode>> nodesByLayer)
{
// Iteratively relax node positions
for (int iteration = 0; iteration < MaxIterations; iteration++)
{
var alpha = 1 - Math.Pow(0.001, 1.0 / MaxIterations);
// Forward pass
for (int i = 1; i < nodesByLayer.Count; i++)
{
RelaxLeftToRight(nodesByLayer[i].ToList(), alpha);
}
// Backward pass
for (int i = nodesByLayer.Count - 2; i >= 0; i--)
{
RelaxRightToLeft(nodesByLayer[i].ToList(), alpha);
}
}
}
private void RelaxLeftToRight(List<ComputedSankeyNode> nodes, double alpha)
{
foreach (var node in nodes)
{
if (node.TargetLinks.Count > 0)
{
var y = node.TargetLinks.Sum(l => (l.SourceNode?.Y ?? 0 + l.Y0) * l.Value) / node.ComputedValue;
node.Y += (y - node.Y) * alpha;
}
}
// Resolve overlaps
nodes = nodes.OrderBy(n => n.Y).ToList();
var y0 = 0.0;
foreach (var node in nodes)
{
var dy = y0 - node.Y;
if (dy > 0)
{
node.Y += dy;
}
y0 = node.Y + node.Height + NodePadding;
}
// Adjust to fit within height
if (nodes.Count > 0)
{
// Find min and max Y positions
var minY = nodes.Min(n => n.Y);
var maxY = nodes.Max(n => n.Y + n.Height);
var totalHeight = maxY - minY;
// First, normalize to start at 0
if (minY != 0)
{
foreach (var node in nodes)
{
node.Y -= minY;
}
maxY -= minY;
minY = 0;
}
// Then scale or center
if (totalHeight > Height)
{
// Scale down to fit
var scale = Height / totalHeight;
foreach (var node in nodes)
{
node.Y *= scale;
node.Height *= scale;
}
}
else
{
// Center vertically - but keep within bounds
var offset = Math.Min((Height - totalHeight) / 2, Height * 0.1); // Max 10% of height as offset
foreach (var node in nodes)
{
node.Y += offset;
}
}
}
}
private void RelaxRightToLeft(List<ComputedSankeyNode> nodes, double alpha)
{
foreach (var node in nodes)
{
if (node.SourceLinks.Count > 0)
{
var y = node.SourceLinks.Sum(l => (l.TargetNode?.Y ?? 0 + l.Y1) * l.Value) / node.ComputedValue;
node.Y += (y - node.Y) * alpha;
}
}
// Resolve overlaps
nodes = nodes.OrderBy(n => n.Y).ToList();
var y0 = 0.0;
foreach (var node in nodes)
{
var dy = y0 - node.Y;
if (dy > 0)
{
node.Y += dy;
}
y0 = node.Y + node.Height + NodePadding;
}
// Adjust to fit within height
if (nodes.Count > 0)
{
// Find min and max Y positions
var minY = nodes.Min(n => n.Y);
var maxY = nodes.Max(n => n.Y + n.Height);
var totalHeight = maxY - minY;
// First, normalize to start at 0
if (minY != 0)
{
foreach (var node in nodes)
{
node.Y -= minY;
}
maxY -= minY;
minY = 0;
}
// Then scale or center
if (totalHeight > Height)
{
// Scale down to fit
var scale = Height / totalHeight;
foreach (var node in nodes)
{
node.Y *= scale;
node.Height *= scale;
}
}
else
{
// Center vertically - but keep within bounds
var offset = Math.Min((Height - totalHeight) / 2, Height * 0.1); // Max 10% of height as offset
foreach (var node in nodes)
{
node.Y += offset;
}
}
}
}
private void AssignLinkDepths(List<ComputedSankeyNode> nodes)
{
// Process all nodes to set source widths and Y0 positions
foreach (var node in nodes)
{
var outgoingSum = node.SourceLinks.Sum(l => l.Value);
if (outgoingSum > 0)
{
var y0 = 0.0;
var sortedSourceLinks = node.SourceLinks.OrderBy(l => l.TargetNode?.Y ?? 0).ToList();
foreach (var link in sortedSourceLinks)
{
link.Y0 = y0;
link.WidthSource = (link.Value / outgoingSum) * node.Height;
link.Width = link.WidthSource; // Default width
y0 += link.WidthSource;
}
}
}
// Process all nodes to set target widths and Y1 positions
foreach (var node in nodes)
{
var incomingSum = node.TargetLinks.Sum(l => l.Value);
if (incomingSum > 0)
{
var y1 = 0.0;
var sortedTargetLinks = node.TargetLinks.OrderBy(l => l.SourceNode?.Y ?? 0).ToList();
foreach (var link in sortedTargetLinks)
{
link.Y1 = y1;
link.WidthTarget = (link.Value / incomingSum) * node.Height;
y1 += link.WidthTarget;
}
}
}
}
private void ComputeLinkPaths(List<ComputedSankeyLink> links)
{
foreach (var link in links)
{
if (link.SourceNode == null || link.TargetNode == null)
continue;
var x0 = link.SourceNode.X + link.SourceNode.Width;
var x1 = link.TargetNode.X;
var y0 = link.SourceNode.Y + link.Y0;
var y1 = link.TargetNode.Y + link.Y1;
var widthSource = link.WidthSource > 0 ? link.WidthSource : link.Width;
var widthTarget = link.WidthTarget > 0 ? link.WidthTarget : link.Width;
// Create a smooth curve using cubic bezier that transitions between different widths
var xi = (x0 + x1) / 2;
link.Path = $"M{x0.ToInvariantString()},{y0.ToInvariantString()} " +
$"C{xi.ToInvariantString()},{y0.ToInvariantString()} " +
$"{xi.ToInvariantString()},{y1.ToInvariantString()} " +
$"{x1.ToInvariantString()},{y1.ToInvariantString()} " +
$"L{x1.ToInvariantString()},{(y1 + widthTarget).ToInvariantString()} " +
$"C{xi.ToInvariantString()},{(y1 + widthTarget).ToInvariantString()} " +
$"{xi.ToInvariantString()},{(y0 + widthSource).ToInvariantString()} " +
$"{x0.ToInvariantString()},{(y0 + widthSource).ToInvariantString()} " +
$"Z";
}
}
}
}