mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-02-04 05:35:44 +00:00
482 lines
18 KiB
C#
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";
|
|
}
|
|
}
|
|
}
|
|
} |