Sankey diagram in WPF

In this post we’ll show one possible way of using MindFusion WPF Diagram with custom model objects, and will create a simple Sankey diagram as an example.

Start by creating a new WPF project and adding the MindFusion.Diagramming.Wpf package in NuGet Package Manager. After installing the package, you should be able to add a Diagram element in Xaml:

xmlns:d="http://mindfusion.eu/diagramming/wpf"
...
<Grid>
	<d:Diagram x:Name="diagram"></d:Diagram>
</Grid>

Now let’s define some model classes that raise an event when the diagram view need to change:

class ModelBase : INotifyPropertyChanged
{
	protected void RaisePropertyChanged(string propertyName)
	{
		if (PropertyChanged != null)
			PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
	}

	public event PropertyChangedEventHandler PropertyChanged;
}

class SankeyFlow : ModelBase
{
	public event EventHandler LayoutChanged;

	public double Amount
	{
		get { return amount; }
		set
		{
			if (amount != value)
			{
				amount = value;
				RaisePropertyChanged("Amount");

				if (LayoutChanged != null)
					LayoutChanged(this, EventArgs.Empty);
			}
		}
	}

	public Color Color
	{
		get { return color; }
		set
		{
			if (color != value)
			{
				color = value;
				RaisePropertyChanged("Color");
			}
		}
	}

	internal SankeyNode Source { get; set; }
	internal SankeyNode Target { get; set; }

	private double amount;
	private Color color = Colors.Red;
}

class SankeyNode : ModelBase
{
	public SankeyNode()
	{
		InFlows = new ObservableCollection<SankeyFlow>();
		InFlows.CollectionChanged += OnInFlowsChanged;

		OutFlows = new ObservableCollection<SankeyFlow>();
		OutFlows.CollectionChanged += OnOutFlowsChanged;
	}

	void OnInFlowsChanged(object sender, NotifyCollectionChangedEventArgs e)
	{
		if (e.OldItems != null)
		{
			foreach (SankeyFlow oldFlow in e.OldItems)
				oldFlow.LayoutChanged -= OnFlowLayoutChanged;
		}

		if (e.NewItems != null)
		{
			foreach (SankeyFlow newFlow in e.NewItems)
				newFlow.LayoutChanged += OnFlowLayoutChanged;
		}

		if (LayoutChanged != null)
			LayoutChanged(this, EventArgs.Empty);
	}

	void OnOutFlowsChanged(object sender, NotifyCollectionChangedEventArgs e)
	{
		if (e.OldItems != null)
		{
			foreach (SankeyFlow oldFlow in e.OldItems)
				oldFlow.LayoutChanged -= OnFlowLayoutChanged;
		}

		if (e.NewItems != null)
		{
			foreach (SankeyFlow newFlow in e.NewItems)
				newFlow.LayoutChanged += OnFlowLayoutChanged;
		}

		if (LayoutChanged != null)
			LayoutChanged(this, EventArgs.Empty);
	}

	void OnFlowLayoutChanged(object sender, EventArgs e)
	{
		if (LayoutChanged != null)
			LayoutChanged(this, EventArgs.Empty);
	}

	internal void CalcFlow()
	{
		TotalInFlow = InFlows.Aggregate(
			0.0, (sum, flow) => sum + flow.Amount);
		TotalOutFlow = OutFlows.Aggregate(
			0.0, (sum, flow) => sum + flow.Amount);

		TotalFlow = Math.Max(TotalInFlow, TotalOutFlow);

		foreach (var flow in InFlows)
			flow.Target = this;
		foreach (var flow in OutFlows)
			flow.Source = this;
	}

	public event EventHandler LayoutChanged;

	public ObservableCollection<SankeyFlow> InFlows { get; private set; }
	public ObservableCollection<SankeyFlow> OutFlows { get; private set; }

	internal double TotalInFlow { get; set; }
	internal double TotalOutFlow { get; set; }
	internal double TotalFlow { get; set; }

	public Color Color
	{
		get { return color; }
		set
		{
			if (color != value)
			{
				color = value;
				RaisePropertyChanged("Color");
			}
		}
	}

	private Color color = Colors.DarkBlue;
}

...

Create a DiagramAdapter class that will visualize the model using MindFusion.Diagramming API. To avoid recalculating positions on each change of the model structure, the LayoutChanged handler will call Dispatcher.BeginInvoke to rearrange diagram on next iteration of the message loop. This allow us to update the diagram just once for a batch of changes in the model:

class DiagramAdapter
{
	public DiagramAdapter(SankeyModel model, Diagram diagram)
	{
		this.model = model;
		this.diagram = diagram;

		model.LayoutChanged += OnModelLayoutChanged;
	}

	void OnModelLayoutChanged(object sender, EventArgs e)
	{
		if (arrangePending)
			return;

		arrangePending = true;
		diagram.Dispatcher.BeginInvoke(
			DispatcherPriority.Normal,
			new System.Action(ArrangeDiagram));
	}

	void ArrangeDiagram()
	{
		ArrangeNodes();
		ArrangeLinks();

		arrangePending = false;
	}

...

We will using two dictionaries to map the model objects to diagram objects:

DiagramNode GetView(SankeyNode node)
{
	if (nodeViews.ContainsKey(node))
		return nodeViews[node];

	var viewNode = diagram.Factory.
		CreateShapeNode(nodeRect, Shapes.Rectangle);
	viewNode.Brush = new SolidColorBrush(node.Color);
	viewNode.Stroke = Brushes.Transparent;
	nodeViews[node] = viewNode;
	return viewNode;
}
Dictionary<SankeyNode, DiagramNode> nodeViews = new Dictionary<SankeyNode, DiagramNode>();

DiagramLink GetView(SankeyFlow flow)
{
	if (flowViews.ContainsKey(flow))
		return flowViews[flow];

	var sourceNode = nodeViews[flow.Source];
	var targetNode = nodeViews[flow.Target];

	var viewLink = diagram.Factory.
		CreateDiagramLink(sourceNode, targetNode);
	viewLink.Shape = LinkShape.Bezier;
	viewLink.HeadShape = null;

	flowViews[flow] = viewLink;
	return viewLink;
}
Dictionary<SankeyFlow, DiagramLink> flowViews = new Dictionary<SankeyFlow, DiagramLink>();

We can lay out simple diagrams by directly setting Bounds property of diagram nodes and ControlPoints of DiagramLinks:

void ArrangeNodes()
{
	double x = margin;
	foreach (var column in model.Columns)
	{
		double y = margin;

		foreach (var node in column.Nodes)
		{
			node.CalcFlow();

			var diagramNode = GetView(node);
			diagramNode.Move(x, y);
			diagramNode.Resize(
				diagramNode.Bounds.Width,
				node.TotalFlow * flowUnitResolution);
			y += diagramNode.Bounds.Height + padding;
		}

		x += nodeRect.Width;
		x += columnDistance;
	}
}

void ArrangeLinks()
{
	foreach (var column in model.Columns)
	{
		foreach (var node in column.Nodes)
		{
			var diagramNode = GetView(node);
			var y = diagramNode.Bounds.Y;
			foreach (var flow in node.OutFlows)
			{
				var diagramLink = GetView(flow);
				var thickness = flow.Amount * flowUnitResolution;
				diagramLink.StrokeThickness = thickness;

				y += thickness / 2;
				var startPoint = diagramLink.StartPoint;
				startPoint.Y = y;
				diagramLink.StartPoint = startPoint;
				y += thickness / 2;

				diagramLink.Stroke = SemiTranparent(flow.Color);
				diagramLink.HeadShape = null;
			}
		}
	}

	foreach (var column in model.Columns)
	{
		foreach (var node in column.Nodes)
		{
			var diagramNode = GetView(node);
			var y = diagramNode.Bounds.Y;
			foreach (var flow in node.InFlows)
			{
				var diagramLink = GetView(flow);

				var thickness = flow.Amount * flowUnitResolution;
				y += thickness / 2;
				var endPoint = diagramLink.EndPoint;
				endPoint.Y = y;
				diagramLink.EndPoint = endPoint;
				y += thickness / 2;
			}
		}
	}

	foreach (var link in diagram.Links)
	{
		var p1 = link.ControlPoints[1];
		p1.Y = link.StartPoint.Y;
		link.ControlPoints[1] = p1;
		var p2 = link.ControlPoints[2];
		p2.Y = link.EndPoint.Y;
		link.ControlPoints[2] = p2;
		link.UpdateFromPoints();
	}
}

For more complex diagrams, you could apply graph layout classes provided by MindFusion.Diagramming such as LayeredLayout or OrthogonalLayout.

With adapter code in place, create a sample custom model in MainWindow:

public MainWindow()
{
	InitializeComponent();

	model = new SankeyModel();
	adapter = new DiagramAdapter(model, diagram);

	PopulateModel();
}

void PopulateModel()
{
	model.Columns.Add(new SankeyColumn());
	model.Columns.Add(new SankeyColumn());
	model.Columns.Add(new SankeyColumn());

	var c0Node0 = new SankeyNode();
	model.Columns[0].Nodes.Add(c0Node0);

	var c1Node0 = new SankeyNode();
	var c1Node1 = new SankeyNode();
	model.Columns[1].Nodes.Add(c1Node0);
	model.Columns[1].Nodes.Add(c1Node1);

	var c2Node0 = new SankeyNode();
	var c2Node1 = new SankeyNode();
	model.Columns[2].Nodes.Add(c2Node0);
	model.Columns[2].Nodes.Add(c2Node1);

	c0Node0.OutFlows.Add(new SankeyFlow { Amount = 5, Color = Colors.Red });
	c0Node0.OutFlows.Add(new SankeyFlow { Amount = 2, Color = Colors.Yellow });
	c0Node0.OutFlows.Add(new SankeyFlow { Amount = 4, Color = Colors.Blue });
	c0Node0.OutFlows.Add(new SankeyFlow { Amount = 3, Color = Colors.Magenta });
	c0Node0.OutFlows.Add(new SankeyFlow { Amount = 6, Color = Colors.Green });

	c1Node0.InFlows.Add(c0Node0.OutFlows[0]);
	c1Node0.InFlows.Add(c0Node0.OutFlows[1]);
	c1Node0.InFlows.Add(c0Node0.OutFlows[3]);

	c1Node1.InFlows.Add(c0Node0.OutFlows[2]);
	c1Node1.InFlows.Add(c0Node0.OutFlows[4]);

	c1Node0.OutFlows.Add(new SankeyFlow { Amount = 3, Color = Colors.Goldenrod });
	c1Node0.OutFlows.Add(new SankeyFlow { Amount = 7, Color = Colors.Tomato });
	c1Node1.OutFlows.Add(new SankeyFlow { Amount = 5, Color = Colors.DeepSkyBlue });
	c1Node1.OutFlows.Add(new SankeyFlow { Amount = 5, Color = Colors.Bisque });

	c2Node0.InFlows.Add(c1Node0.OutFlows[0]);
	c2Node1.InFlows.Add(c1Node1.OutFlows[0]);
	c2Node1.InFlows.Add(c1Node0.OutFlows[1]);
	c2Node1.InFlows.Add(c1Node1.OutFlows[1]);
}

SankeyModel model;
DiagramAdapter adapter;

This should display the diagram shown below:

The complete sample project is available for download here:
https://mindfusion.eu/_samples/SankeyDiagram.zip

Enjoy!