Network Graph
Install:
dotnet add package MatPlotLibNet
NetworkGraph draws nodes and edges in 2D. Use it for correlation networks, lead-lag
flows, Louvain community visualisation, and minimum spanning trees. Four layouts place
the nodes: Manual (uses the coordinates you pass), Circular (unit circle),
Hierarchical (BFS top-down) and ForceDirected (Fruchterman–Reingold, seeded, so the
same seed gives the same picture).
Basic graph
GraphNode[] nodes =
[
new("AAPL"),
new("MSFT"),
new("GOOG"),
];
GraphEdge[] edges =
[
new("AAPL", "MSFT"),
new("AAPL", "GOOG"),
new("MSFT", "GOOG"),
];
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.NetworkGraph(nodes, edges))
.ToSvg();
The default Circular layout places nodes evenly on the unit circle. You do not have to
supply coordinates yourself.
Directed edges with weights
GraphEdge[] edges =
[
new("AAPL", "MSFT", Weight: 0.85, IsDirected: true),
new("AAPL", "GOOG", Weight: 0.42, IsDirected: true),
new("MSFT", "GOOG", Weight: 0.55, IsDirected: false),
];
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.NetworkGraph(nodes, edges, s =>
{
s.ShowEdgeWeights = true;
s.EdgeThicknessScale = 4.0; // multiplier on per-edge Weight → stroke width
}))
.ToSvg();
An edge with IsDirected set gets an arrowhead at the target end. It reuses
ArrowHeadBuilder.FancyArrow, so it looks the same as an annotation arrow.
Per-node colour and size
GraphNode[] nodes =
[
new("AAPL", ColorScalar: 0.2, SizeScalar: 1.5), // small, dark colour-map sample
new("MSFT", ColorScalar: 0.5, SizeScalar: 2.5),
new("GOOG", ColorScalar: 0.8, SizeScalar: 3.0), // large, bright sample
];
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.NetworkGraph(nodes, edges, s =>
{
s.ColorMap = ColorMaps.Viridis;
s.NodeRadiusScale = 8.0; // multiplier on per-node SizeScalar
}))
.ToSvg();
The renderer maps ColorScalar through ColorMap, which defaults to Viridis, and clamps
the value to [0, 1]. A node's pixel radius is SizeScalar multiplied by
NodeRadiusScale.
Layouts
Circular (default)
This layout puts every node on the unit circle at evenly spaced angles. It ignores the
edges and it ignores any pre-set X/Y. It is O(N) and deterministic. It is a good first
pick for any small graph.
Hierarchical
This layout walks the graph breadth-first from node 0 and lays out one layer per depth, top down. Depth becomes Y. The order within a depth becomes X, centred around 0, with a single-node layer at X=0. A visited set handles cycles. Disconnected components stay at depth 0.
s.Layout = GraphLayout.Hierarchical;
Manual
This layout uses each node's pre-set X / Y exactly as you set it. Use it when you have
a custom layout algorithm of your own, for example from a t-SNE embedding, and want
NetworkGraph only to render.
GraphNode[] nodes =
[
new("a", X: 0, Y: 0),
new("b", X: 1, Y: 1),
new("c", X: 2, Y: 0),
];
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.NetworkGraph(nodes, edges, s =>
{
s.Layout = GraphLayout.Manual;
}))
.ToSvg();
ForceDirected (Fruchterman–Reingold spring-embedder)
Every pair of nodes pushes apart with a repulsive force of k²/d. Every edge pulls its
two nodes together with a spring force of d²/k. Here k = √(area/N). The starting
positions are random and seeded by LayoutSeed (default 0), so repeated runs are
bit-identical. The step size cools linearly across the iterations. The final positions are
normalised to fit [-1, 1]².
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.NetworkGraph(nodes, edges, s =>
{
s.Layout = GraphLayout.ForceDirected;
s.LayoutSeed = 42; // any int — same seed → identical layout
s.LayoutIterations = 100; // default 50; higher = better quality at quadratic cost
s.ConvergenceThreshold = 0.5; // optional: early-stop when energy delta drops below
}))
.ToSvg();
⚠️ Performance limit. ForceDirected is O(N² × iterations) for the repulsive pass. At default 50 iterations the practical limit is roughly N ≤ 500. Beyond that, render time grows quadratically: N=1000 takes seconds, N=5000 takes minutes. For larger graphs, switch to
GraphLayout.Hierarchical(O(N + E)) orGraphLayout.Circular(O(N)). Edge density matters too: dense graphs (E ≈ N²/2) add measurable spring-force overhead on top of the repulsion, which is always quadratic. SeeBenchmarks/MatPlotLibNet.Benchmarks/NetworkGraphBenchmarks.csfor the full N × edge-density matrix.
Seeded determinism. Two figures rendered with the same LayoutSeed, LayoutIterations
and ConvergenceThreshold produce byte-identical SVG. That makes ForceDirected testable,
and it lets you use snapshot workflows that need the same layout on every build.
Convergence-mode early-stop. Set ConvergenceThreshold to a positive value and the
loop exits as soon as the total displacement energy of an iteration drops below that
threshold. Sparse or well-separated topologies, such as a clear cluster structure,
converge in 10–20 iterations and benefit from this. Dense uniform graphs usually do not
converge below any reasonable threshold and run the full LayoutIterations count.
DataFrame extension
For an edge-list DataFrame, df.NetworkGraph(...) builds the nodes from the union of the
distinct values in the source and target columns, in first-seen order:
using MatPlotLibNet;
using Microsoft.Data.Analysis;
DataFrame df = ...; // columns: source, target, weight, directed
string svg = df.NetworkGraph(
edgeFromCol: "source",
edgeToCol: "target",
weightCol: "weight",
directedCol: "directed")
.WithTitle("Asset correlation network")
.ToSvg();
weightCol and directedCol are optional. Missing nodes, meaning edges that reference an
ID that is not in the union, are silently skipped at render time. This is a defensive
fallback: the renderer does not throw.
Configuration reference
| Property | Type | Default | Effect |
|---|---|---|---|
Nodes |
IReadOnlyList<GraphNode> |
(constructor arg) | The graph's nodes. |
Edges |
IReadOnlyList<GraphEdge> |
(constructor arg) | The graph's edges. |
Layout |
GraphLayout |
Circular |
Manual / Circular / Hierarchical / ForceDirected. |
ColorMap |
IColorMap? |
null, meaning Viridis |
Per-node colour from ColorScalar. |
ShowNodeLabels |
bool |
true |
Render Label (or Id) next to each node. |
ShowEdgeWeights |
bool |
false |
Render numeric weight on top of each edge. |
EdgeThicknessScale |
double |
1.0 |
Multiplier on per-edge Weight to get the stroke width. |
NodeRadiusScale |
double |
5.0 |
Multiplier on per-node SizeScalar to get the circle radius. |
LayoutSeed |
int |
0 |
Seed for ForceDirected RNG. Ignored by deterministic layouts. |
LayoutIterations |
int |
50 |
Max iterations for ForceDirected (O(N²) per iteration). Ignored by deterministic layouts. |
ConvergenceThreshold |
double? |
null |
Optional early-stop for ForceDirected when the per-iteration energy drops below this. Null runs the full LayoutIterations. |
See also
- Dashboard tiles & timelines — the same graph as a control-room topology panel: fixed seed, no axes, the alarm ramp on the nodes
- Sankey Diagrams — flow visualisation when edges have explicit thicknesses
- Treemaps — hierarchical containers when the relationship is strictly nesting
- Dendrograms — hierarchical clustering trees with merge distances