WPF Sankey Diagram


#1

Hi,

Is it possible to generate Sankey diagrams with the WPF version of the library? If not is it a feature that you are planning to implement in the near future?

JS version looks great:
https://gojs.net/latest/samples/sankey.html

Thanks.


#2

I think this should be quite possible. The implementation will be similar to what GoJS does. When I get a chance later today I can see if I can get a start on such a sample.


#3

Thanks Walter, much appreciated.


#4

Hey @walter did you get a chance to check this yet?


#5

Sorry, I caught a cold and am not in a position to do significant things for a while until I get better.


#6

oh ok, get well soon Walter.


#7

Here you go. First the Sankey.xaml file:

<!-- Copyright © Northwoods Software Corporation, 2008-2018. All Rights Reserved. -->

<UserControl x:Class="Sankey.Sankey"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:go="http://schemas.nwoods.com/GoXam"
    xmlns:local="clr-namespace:Sankey">

  <UserControl.Resources>
    <!-- for converting a color string to a Brush -->
    <go:StringBrushConverter x:Key="theBrushConverter" />

    <DataTemplate x:Key="NodeTemplate">
      <StackPanel Orientation="Horizontal"
                  go:Node.LocationElementName="SHAPE"
                  go:Node.LocationSpot="MiddleLeft">
        <TextBlock x:Name="LTEXT" Text="{Binding Path=Data.LText}" Margin="5"
                   FontWeight="Bold" FontSize="12pt" />
        <Rectangle x:Name="SHAPE"
                   StrokeThickness="0"
                   go:Node.PortId="" 
                   go:Node.FromSpot="RightSide"
                   go:Node.ToSpot="LeftSide"
                   Height="10"
                   Width="20"
                   Fill="{Binding Path=Data.Color, Converter={StaticResource theBrushConverter}}" />
        <TextBlock x:Name="TEXT" Text="{Binding Path=Data.Text}" Margin="5"
                   FontWeight="Bold" FontSize="12pt" />
      </StackPanel>
    </DataTemplate>
    
    <DataTemplate x:Key="LinkTemplate">
      <go:LinkPanel go:Part.LayerName="Background">
        <go:Link.Route>
          <go:Route Curve="Bezier" Adjusting="End"
                    FromEndSegmentLength="150" ToEndSegmentLength="150" />
        </go:Link.Route>
        <go:LinkShape Stroke="{Binding Path=Link.FromNode.Data.Color, Converter={StaticResource theBrushConverter}}"
                      Opacity="0.4"
                      StrokeThickness="{Binding Path=Data.Width}" />
      </go:LinkPanel>
    </DataTemplate>
  </UserControl.Resources>

  <Grid>
    <go:Diagram x:Name="myDiagram"
                InitialStretch="UniformToFill"
                NodeTemplate="{StaticResource NodeTemplate}"
                LinkTemplate="{StaticResource LinkTemplate}">
      <go:Diagram.Layout>
        <local:SankeyLayout SetsPortSpots="False"
                            LayerSpacing="150"
                            ColumnSpacing="1" />
      </go:Diagram.Layout>
    </go:Diagram>
  </Grid>
</UserControl>

And finally the Sankey.xaml.cs file:

/* Copyright © Northwoods Software Corporation, 2008-2018. All Rights Reserved. */

using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Controls;
using Northwoods.GoXam;
using Northwoods.GoXam.Layout;
using Northwoods.GoXam.Model;

namespace Sankey {
  public partial class Sankey : UserControl {
    public Sankey() {
      InitializeComponent();

      var nodes = new ObservableCollection<SankeyNodeData>() {
new SankeyNodeData() { Key="Coal reserves", Text="Coal reserves", Color="#9d75c2" },
new SankeyNodeData() { Key="Coal imports", Text="Coal imports", Color="#9d75c2" },
new SankeyNodeData() { Key="Oil reserves", Text="Oil\nreserves", Color="#9d75c2" },
new SankeyNodeData() { Key="Oil imports", Text="Oil imports", Color="#9d75c2" },
new SankeyNodeData() { Key="Gas reserves", Text="Gas reserves", Color="#a1e194" },
new SankeyNodeData() { Key="Gas imports", Text="Gas imports", Color="#a1e194" },
new SankeyNodeData() { Key="UK land based bioenergy", Text="UK land based bioenergy", Color="#f6bcd5" },
new SankeyNodeData() { Key="Marine algae", Text="Marine algae", Color="#681313" },
new SankeyNodeData() { Key="Agricultural 'waste'", Text="Agricultural 'waste'", Color="#3483ba" },
new SankeyNodeData() { Key="Other waste", Text="Other waste", Color="#c9b7d8" },
new SankeyNodeData() { Key="Biomass imports", Text="Biomass imports", Color="#fea19f" },
new SankeyNodeData() { Key="Biofuel imports", Text="Biofuel imports", Color="#d93c3c" },
new SankeyNodeData() { Key="Coal", Text="Coal", Color="#9d75c2" },
new SankeyNodeData() { Key="Oil", Text="Oil", Color="#9d75c2" },
new SankeyNodeData() { Key="Natural gas", Text="Natural\ngas", Color="#a6dce6" },
new SankeyNodeData() { Key="Solar", Text="Solar", Color="#c9a59d" },
new SankeyNodeData() { Key="Solar PV", Text="Solar PV", Color="#c9a59d" },
new SankeyNodeData() { Key="Bio-conversion", Text="Bio-conversion", Color="#b5cbe9" },
new SankeyNodeData() { Key="Solid", Text="Solid", Color="#40a840" },
new SankeyNodeData() { Key="Liquid", Text="Liquid", Color="#fe8b25" },
new SankeyNodeData() { Key="Gas", Text="Gas", Color="#a1e194" },
new SankeyNodeData() { Key="Nuclear", Text="Nuclear", Color="#fea19f" },
new SankeyNodeData() { Key="Thermal generation", Text="Thermal\ngeneration", Color="#3483ba" },
new SankeyNodeData() { Key="CHP", Text="CHP", Color="yellow" },
new SankeyNodeData() { Key="Electricity imports", Text="Electricity imports", Color="yellow" },
new SankeyNodeData() { Key="Wind", Text="Wind", Color="#cbcbcb" },
new SankeyNodeData() { Key="Tidal", Text="Tidal", Color="#6f3a5f" },
new SankeyNodeData() { Key="Wave", Text="Wave", Color="#8b8b8b" },
new SankeyNodeData() { Key="Geothermal", Text="Geothermal", Color="#556171" },
new SankeyNodeData() { Key="Hydro", Text="Hydro", Color="#7c3e06" },
new SankeyNodeData() { Key="Electricity grid", Text="Electricity grid", Color="#e483c7" },
new SankeyNodeData() { Key="H2 conversion", Text="H2 conversion", Color="#868686" },
new SankeyNodeData() { Key="Solar Thermal", Text="Solar Thermal", Color="#c9a59d" },
new SankeyNodeData() { Key="H2", Text="H2", Color="#868686" },
new SankeyNodeData() { Key="Pumped heat", Text="Pumped heat", Color="#96665c" },
new SankeyNodeData() { Key="District heating", Text="District heating", Color="#c9b7d8" },
new SankeyNodeData() { Key="Losses", LText="Losses", Color="#fec184" },
new SankeyNodeData() { Key="Over generation / exports", LText="Over generation / exports", Color="#f6bcd5" },
new SankeyNodeData() { Key="Heating and cooling - homes", LText="Heating and cooling - homes", Color="#c7a39b" },
new SankeyNodeData() { Key="Road transport", LText="Road transport", Color="#cbcbcb" },
new SankeyNodeData() { Key="Heating and cooling - commercial", LText="Heating and cooling - commercial", Color="#c9a59d" },
new SankeyNodeData() { Key="Industry", LText="Industry", Color="#96665c" },
new SankeyNodeData() { Key="Lighting & appliances - homes", LText="Lighting & appliances - homes", Color="#2dc3d2" },
new SankeyNodeData() { Key="Lighting & appliances - commercial", LText="Lighting & appliances - commercial", Color="#2dc3d2" },
new SankeyNodeData() { Key="Agriculture", LText="Agriculture", Color="#5c5c10" },
new SankeyNodeData() { Key="Rail transport", LText="Rail transport", Color="#6b6b45" },
new SankeyNodeData() { Key="Domestic aviation", LText="Domestic aviation", Color="#40a840" },
new SankeyNodeData() { Key="National navigation", LText="National navigation", Color="#a1e194" },
new SankeyNodeData() { Key="International aviation", LText="International aviation", Color="#fec184" },
new SankeyNodeData() { Key="International shipping", LText="International shipping", Color="#fec184" },
new SankeyNodeData() { Key="Geosequestration", LText="Geosequestration", Color="#fec184" }
      };

      var links = new ObservableCollection<SankeyLinkData>() {
new SankeyLinkData() { From="Coal reserves", To="Coal", Width=31 },
new SankeyLinkData() { From="Coal imports", To="Coal", Width=86 },
new SankeyLinkData() { From="Oil reserves", To="Oil", Width=244 },
new SankeyLinkData() { From="Oil imports", To="Oil", Width=1 },
new SankeyLinkData() { From="Gas reserves", To="Natural gas", Width=182 },
new SankeyLinkData() { From="Gas imports", To="Natural gas", Width=61 },
new SankeyLinkData() { From="UK land based bioenergy", To="Bio-conversion", Width=1 },
new SankeyLinkData() { From="Marine algae", To="Bio-conversion", Width=1 },
new SankeyLinkData() { From="Agricultural 'waste'", To="Bio-conversion", Width=1 },
new SankeyLinkData() { From="Other waste", To="Bio-conversion", Width=8 },
new SankeyLinkData() { From="Other waste", To="Solid", Width=1 },
new SankeyLinkData() { From="Biomass imports", To="Solid", Width=1 },
new SankeyLinkData() { From="Biofuel imports", To="Liquid", Width=1 },
new SankeyLinkData() { From="Coal", To="Solid", Width=117 },
new SankeyLinkData() { From="Oil", To="Liquid", Width=244 },
new SankeyLinkData() { From="Natural gas", To="Gas", Width=244 },
new SankeyLinkData() { From="Solar", To="Solar PV", Width=1 },
new SankeyLinkData() { From="Solar PV", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Solar", To="Solar Thermal", Width=1 },
new SankeyLinkData() { From="Bio-conversion", To="Solid", Width=3 },
new SankeyLinkData() { From="Bio-conversion", To="Liquid", Width=1 },
new SankeyLinkData() { From="Bio-conversion", To="Gas", Width=5 },
new SankeyLinkData() { From="Bio-conversion", To="Losses", Width=1 },
new SankeyLinkData() { From="Solid", To="Over generation / exports", Width=1 },
new SankeyLinkData() { From="Liquid", To="Over generation / exports", Width=18 },
new SankeyLinkData() { From="Gas", To="Over generation / exports", Width=1 },
new SankeyLinkData() { From="Solid", To="Thermal generation", Width=106 },
new SankeyLinkData() { From="Liquid", To="Thermal generation", Width=2 },
new SankeyLinkData() { From="Gas", To="Thermal generation", Width=87 },
new SankeyLinkData() { From="Nuclear", To="Thermal generation", Width=41 },
new SankeyLinkData() { From="Thermal generation", To="District heating", Width=2 },
new SankeyLinkData() { From="Thermal generation", To="Electricity grid", Width=92 },
new SankeyLinkData() { From="Thermal generation", To="Losses", Width=142 },
new SankeyLinkData() { From="Solid", To="CHP", Width=1 },
new SankeyLinkData() { From="Liquid", To="CHP", Width=1 },
new SankeyLinkData() { From="Gas", To="CHP", Width=1 },
new SankeyLinkData() { From="CHP", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="CHP", To="Losses", Width=1 },
new SankeyLinkData() { From="Electricity imports", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Wind", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Tidal", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Wave", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Geothermal", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Hydro", To="Electricity grid", Width=1 },
new SankeyLinkData() { From="Electricity grid", To="H2 conversion", Width=1 },
new SankeyLinkData() { From="Electricity grid", To="Over generation / exports", Width=1 },
new SankeyLinkData() { From="Electricity grid", To="Losses", Width=6 },
new SankeyLinkData() { From="Gas", To="H2 conversion", Width=1 },
new SankeyLinkData() { From="H2 conversion", To="H2", Width=1 },
new SankeyLinkData() { From="H2 conversion", To="Losses", Width=1 },
new SankeyLinkData() { From="Solar Thermal", To="Heating and cooling - homes", Width=1 },
new SankeyLinkData() { From="H2", To="Road transport", Width=1 },
new SankeyLinkData() { From="Pumped heat", To="Heating and cooling - homes", Width=1 },
new SankeyLinkData() { From="Pumped heat", To="Heating and cooling - commercial", Width=1 },
new SankeyLinkData() { From="CHP", To="Heating and cooling - homes", Width=1 },
new SankeyLinkData() { From="CHP", To="Heating and cooling - commercial", Width=1 },
new SankeyLinkData() { From="District heating", To="Heating and cooling - homes", Width=1 },
new SankeyLinkData() { From="District heating", To="Heating and cooling - commercial", Width=1 },
new SankeyLinkData() { From="District heating", To="Industry", Width=2 },
new SankeyLinkData() { From="Electricity grid", To="Heating and cooling - homes", Width=7 },
new SankeyLinkData() { From="Solid", To="Heating and cooling - homes", Width=3 },
new SankeyLinkData() { From="Liquid", To="Heating and cooling - homes", Width=3 },
new SankeyLinkData() { From="Gas", To="Heating and cooling - homes", Width=81 },
new SankeyLinkData() { From="Electricity grid", To="Heating and cooling - commercial", Width=7 },
new SankeyLinkData() { From="Solid", To="Heating and cooling - commercial", Width=1 },
new SankeyLinkData() { From="Liquid", To="Heating and cooling - commercial", Width=2 },
new SankeyLinkData() { From="Gas", To="Heating and cooling - commercial", Width=19 },
new SankeyLinkData() { From="Electricity grid", To="Lighting & appliances - homes", Width=21 },
new SankeyLinkData() { From="Gas", To="Lighting & appliances - homes", Width=2 },
new SankeyLinkData() { From="Electricity grid", To="Lighting & appliances - commercial", Width=18 },
new SankeyLinkData() { From="Gas", To="Lighting & appliances - commercial", Width=2 },
new SankeyLinkData() { From="Electricity grid", To="Industry", Width=30 },
new SankeyLinkData() { From="Solid", To="Industry", Width=13 },
new SankeyLinkData() { From="Liquid", To="Industry", Width=34 },
new SankeyLinkData() { From="Gas", To="Industry", Width=54 },
new SankeyLinkData() { From="Electricity grid", To="Agriculture", Width=1 },
new SankeyLinkData() { From="Solid", To="Agriculture", Width=1 },
new SankeyLinkData() { From="Liquid", To="Agriculture", Width=1 },
new SankeyLinkData() { From="Gas", To="Agriculture", Width=1 },
new SankeyLinkData() { From="Electricity grid", To="Road transport", Width=1 },
new SankeyLinkData() { From="Liquid", To="Road transport", Width=122 },
new SankeyLinkData() { From="Electricity grid", To="Rail transport", Width=2 },
new SankeyLinkData() { From="Liquid", To="Rail transport", Width=1 },
new SankeyLinkData() { From="Liquid", To="Domestic aviation", Width=2 },
new SankeyLinkData() { From="Liquid", To="National navigation", Width=4 },
new SankeyLinkData() { From="Liquid", To="International aviation", Width=38 },
new SankeyLinkData() { From="Liquid", To="International shipping", Width=13 },
new SankeyLinkData() { From="Electricity grid", To="Geosequestration", Width=1 },
new SankeyLinkData() { From="Gas", To="Losses", Width=2 }
      };

      var model = new GraphLinksModel<SankeyNodeData, String, String, SankeyLinkData>();
      model.NodesSource = nodes;
      model.LinksSource = links;
      myDiagram.Model = model;
    }
  }

  public class SankeyNodeData : GraphLinksModelNodeData<String> {
    public String LText { get; set; }
    public String Color { get; set; }
  }

  public class SankeyLinkData : GraphLinksModelLinkData<String, String> {
    public double Width { get; set; }
  }

  public class SankeyLayout : LayeredDigraphLayout {
    public SankeyLayout() {
      this.PackOption = LayeredDigraphPack.Straighten | LayeredDigraphPack.Median;
    }

    // Before creating the LayeredDigraphNetwork of vertexes and edges,
    // determine the desired height of each node (Shape).
    public override LayeredDigraphNetwork CreateNetwork() {
      foreach (var node in this.Diagram.Nodes) {
        var height = getAutoHeightForNode(node);
        var fontsize = Math.Max(12, Math.Round(height / 8));
        var shape = node.FindNamedDescendant("SHAPE");
        var text = node.FindNamedDescendant("TEXT") as TextBlock;
        var ltext = node.FindNamedDescendant("LTEXT") as TextBlock;
        if (shape != null) shape.Height = height;
        if (text != null) text.FontSize = fontsize;
        if (ltext != null) ltext.FontSize = fontsize;
        node.Remeasure();
      }
      return base.CreateNetwork();
    }

    double getAutoHeightForNode(Node node) {
      var heightIn = 0.0;
      foreach (var link in node.LinksInto) heightIn += ComputeThickness(link);
      var heightOut = 0.0;
      foreach (var link in node.LinksOutOf) heightOut += ComputeThickness(link);
      var h = Math.Max(heightIn, heightOut);
      if (h < 10) h = 10;
      return h;
    }

    public static double ComputeThickness(Link link) {
      if (link == null) return 1;
      var data = link.Data as SankeyLinkData;
      if (data != null) return data.Width;
      return 1;
    }

    // treat dummy vertexes as having the thickness of the link that they are in
    protected override int NodeMinColumnSpace(LayeredDigraphVertex v, bool topleft) {
      if (v.Node == null) {
        if (v.EdgesCount >= 1) {
          var max = 1.0;
          foreach (var edge in v.Edges) {
            if (edge.Link != null) {
              var t = ComputeThickness(edge.Link);
              if (t > max) max = t;
              break;
            }
          }
          return (int)Math.Ceiling(max/this.ColumnSpacing);
        }
        return 1;
      }
      return base.NodeMinColumnSpace(v, topleft);
    }

    protected override void AssignLayers() {
      base.AssignLayers();
      var maxlayer = this.MaxLayer;
      // now make sure every vertex with no outputs is maxlayer
      foreach (var v in this.Network.Vertexes) {
        if (v.DestinationVertexes.Count() == 0) {
          v.Layer = 0;
        }
        if (v.SourceVertexes.Count() == 0) {
          v.Layer = maxlayer;
        }
      }
    }

    protected override void LayoutLinks() {
      base.LayoutLinks();
      // need to adjust link.Route.ToSpot and .FromSpot so that the links
      // are stacked on top each other based on their computed thicknesses
      foreach (var v in this.Network.Vertexes) {
        if (v.Node == null) continue;

        var links = v.Node.LinksInto.OrderBy(l => l.Route.Points[l.Route.PointsCount-1].Y).ToList();
        var total = 0.0;
        foreach (var l in links) total += ComputeThickness(l);
        var y = -total/2;
        foreach (var l in links) {
          var t = ComputeThickness(l);
          l.Route.ToSpot = new Spot(0, 0.5, 0, y + t/2);
          y += t;
        }

        links = v.Node.LinksOutOf.OrderBy(l => l.Route.Points[l.Route.PointsCount-1].Y).ToList();
        total = 0.0;
        foreach (var l in links) total += ComputeThickness(l);
        y = -total/2;
        foreach (var l in links) {
          var t = ComputeThickness(l);
          l.Route.FromSpot = new Spot(1, 0.5, 0, y + t/2);
          y += t;
        }
      }

      foreach (var e in this.Network.Edges) {
        if (e.Link == null) continue;
        e.Link.Route.InvalidateRoute();  // let Route.Adjusting keep the intermediate points
      }
    }
  }
  // end of SankeyLayout
}

The result:


#8

Awesome! Thanks @walter