-
Notifications
You must be signed in to change notification settings - Fork 3
Chart Types
Try it live: Open the Playground to experiment with charts in your browser. Browse the Cookbook (25 pages) for copy-paste code examples with rendered output.
MatPlotLibNet has 83 series types across 14 categories — four of them streaming — plus 13 map projections and geographic polygon rendering via MatPlotLibNet.Geo. A test reads the assembly and checks that count (SeriesCountContractTests). Adding a series makes that test fail, so this number cannot go out of date unnoticed. See Geographic / Maps below for everything the geo package adds.
The fidelity suite renders these charts and compares them pixel for pixel against matplotlib. Click a tile for the full-resolution PNG.

Plt.Create().Plot(x, y).Save("line.svg");
Plt.Create().Plot(x, y, s => { s.Color = Color.Blue; s.LineWidth = 2; s.Label = "Series A"; }).Save("line.svg");Plt.Create().Scatter(x, y).Save("scatter.svg");
Plt.Create().Scatter(x, y, s => { s.MarkerStyle = MarkerStyle.Circle; s.MarkerSize = 8; }).Save("scatter.svg");Plt.Create().Bar(["Q1", "Q2", "Q3", "Q4"], [100, 200, 150, 250]).Save("bar.svg");double[] data = /* ... */;
Plt.Create().Hist(data, bins: 20).Save("hist.svg");Plt.Create().Pie(["A", "B", "C"], [40, 35, 25]).Save("pie.svg");
Plt.Create().Donut(["A", "B", "C"], [40, 35, 25]).Save("donut.svg");Plt.Create().Step(x, y).Save("step.svg");
Plt.Create().Fill(x, y1, y2).Save("fill.svg"); // fill between two lines
Plt.Create().ErrorBar(x, y, yErr).Save("errbar.svg");
Plt.Create().Stem(x, y).Save("stem.svg");
Plt.Create().Box(groups).Save("box.svg"); // groups: double[][]
Plt.Create().Violin(groups).Save("violin.svg");Use these series for data of 100 k points or more. Viewport slicing is O(1) for a uniform sample rate, or an O(log n) binary search for non-uniform X, and LTTB downsampling follows. The render path makes no gen-2 GC allocations.
// Uniform sample rate (e.g. audio, sensor data) — O(1) IndexRangeFor
Plt.Create().Signal(samples, sampleRate: 44100).Save("audio.svg");
// Non-uniform ascending X — O(log n) binary search
Plt.Create().SignalXY(xNonUniform, y).Save("signal_xy.svg");
// One-liner shortcuts
QuickPlot.Signal(samples, sampleRate: 44100, title: "Audio").Save("audio.svg");
QuickPlot.SignalXY(x, y).Save("xy.svg");Plt.Create().Kde(data).Save("kde.svg");
Plt.Create().Ecdf(data).Save("ecdf.svg");
Plt.Create().Rugplot(data).Save("rug.svg");
Plt.Create().Stripplot(groups, labels).Save("strip.svg");
Plt.Create().Swarmplot(groups, labels).Save("swarm.svg");
Plt.Create().Pointplot(groups, labels).Save("point.svg");
Plt.Create().Eventplot(events).Save("event.svg");
Plt.Create().Regression(x, y).Save("regression.svg"); // with LeastSquares fit line
Plt.Create().Residual(x, y, yFit).Save("residual.svg");
Plt.Create().Count(categories).Save("count.svg");
FigureTemplates.FinancialDashboard produces a 3-panel layout (price 60 %, volume 15 %, oscillator 25 %) with grid lines, bar-center-aligned tick labels, and optional indicator overlays. Each indicator resolves against the most recently added price series — so .BollingerBands(20) followed by .Sma(5) computes the SMA over the raw close, not the Bollinger middle band.
FigureTemplates.FinancialDashboard(open, high, low, close, volume,
title: "ACME Corp",
configurePricePanel: ax => {
ax.BollingerBands(20); // BB(20, 2σ) with semi-transparent fill
ax.Sma(5);
},
configureOscillatorPanel: ax => {
ax.Rsi(close, 14);
ax.AxHLine(70, rl => { rl.Color = Color.FromHex("#E24A33"); rl.LineStyle = LineStyle.Dashed; });
ax.AxHLine(30, rl => { rl.Color = Color.FromHex("#E24A33"); rl.LineStyle = LineStyle.Dashed; });
})
.WithSize(1200, 700)
.Save("financial_dashboard.svg");Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Candlestick(open, high, low, close)
.Sma(period: 20)
.WithLegend())
.Save("candlestick.svg");Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.Ohlc(open, high, low, close))
.Save("ohlc.svg");
double[,] data = /* 2-D array */;
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Heatmap(data)
.WithColorMap("plasma")
.WithColorBar(cb => cb with { Label = "Intensity" }))
.Save("heatmap.svg");Plt.Create().Contour(x, y, z).Save("contour.svg");
Plt.Create().Contourf(x, y, z).Save("contourf.svg");Plt.Create().Image(bitmap).Save("image.svg");
Plt.Create().Hexbin(x, y).Save("hexbin.svg");
Plt.Create().Pcolormesh(x, y, z).Save("pcolor.svg");
Plt.Create().Tricontour(x, y, z).Save("tricontour.svg");
Plt.Create().Tripcolor(x, y, z).Save("tripcolor.svg");There are twelve 3-D series. They share a perspective camera, per-face lighting, numeric tick marks along the bounding-box edges, and interactive SVG rotation. A shared cross-series depth queue composites multiple 3-D series on one axes correctly, whatever order you add them in.
// Original 6
Plt.Create().Surface3D(x, y, z).Save("surface.svg");
Plt.Create().Wireframe3D(x, y, z).Save("wireframe.svg");
Plt.Create().Scatter3D(x, y, z).Save("scatter3d.svg");
Plt.Create().Bar3D(x, y, heights).Save("bar3d.svg");
Plt.Create().PlanarBar3D(x, y, heights).Save("planar_bars.svg");
Plt.Create().Stem3D(x, y, z).Save("stem3d.svg");
// v1.3.0 — 6 new series
Plt.Create().Plot3D(x, y, z).Save("line3d.svg"); // projected polyline
Plt.Create().Trisurf(x, y, z).Save("trisurf.svg"); // Delaunay triangulated surface
Plt.Create().Contour3D(x, y, z).Save("contour3d.svg"); // marching-squares contour lines
Plt.Create().Quiver3D(x, y, z, u, v, w).Save("quiver3d.svg"); // 3D vector field
Plt.Create().Voxels(filled).Save("voxels.svg"); // face-culled cubes
Plt.Create().Text3D(x, y, z, "label").Save("text3d.svg"); // 3D annotation
This is the equivalent of matplotlib's ax.bar3d(...). It draws rectangular prisms with matplotlib-exact per-face shading (0.65 + 0.35·dot(n̂, l̂)), depth-sorted across all bars so occlusion is correct at any camera angle. Multiple Bar3D calls on one axes stack into a grid:

record Row(double Y, Color Color);
Row[] rows = [new(0, Colors.Red), new(1, Colors.Green),
new(2, Colors.Blue), new(3, Colors.Cyan), new(4, Colors.Gold)];
Plt.Create()
.WithTitle("3D Bar Chart — Grouped rows")
.WithSize(780, 620)
.AddSubPlot(1, 1, 1, ax =>
{
ax.WithCamera(elevation: 25, azimuth: -60)
.WithLighting(dx: -0.4, dy: -0.8, dz: 0.45);
foreach (var (y, color) in rows)
{
double[] ys = Enumerable.Repeat(y, 20).ToArray();
double[] zs = /* ... */;
ax.Bar3D(xs, ys, zs, s => { s.Color = color; s.BarWidth = 0.4; });
}
})
.Save("bar3d_grouped.svg");
This is the equivalent of matplotlib's ax.bar(xs, heights, zs=y, zdir='y'), also called a "skyscraper plot". Each bar is a flat translucent rectangle in the XZ plane at a fixed Y, so you can read the bars through the rear planes. Use it to compare many series stacked on planes.
Plt.Create()
.WithTitle("Planar 3D Bars")
.WithSize(780, 620)
.AddSubPlot(1, 1, 1, ax =>
{
ax.WithCamera(elevation: 25, azimuth: -60)
.SetXLabel("X").SetYLabel("Y").SetZLabel("Z");
foreach (var (y, color) in rows)
{
double[] ys = Enumerable.Repeat(y, 20).ToArray();
double[] zs = /* ... */;
ax.PlanarBar3D(xs, ys, zs, s =>
{
s.Color = color;
s.BarWidth = 0.8;
s.Alpha = 0.8; // translucency
});
}
})
.Save("planar_bars.svg");PlanarBar3DSeries.Colors is a parallel Color[]? array, the same convention as ScatterSeries.Colors / PieSeries.Colors. When you set it, it overrides the per-series Color for each bar. That gives three ways to pick colours from one API:
-
Per Y / per plane — set
s.Color = planeColoron each series -
Per X — set
s.Colors = xs.Select(x => lookup(x)).ToArray() -
Combined — set both:
Colors[i]wins where defined,Coloris the fallback

var darkCyan = Color.FromHex("#008B8B");
ax.PlanarBar3D(xs, ys, zs, s =>
{
s.Color = planeColor;
s.BarWidth = 0.8;
s.Alpha = 0.8;
// Per-X override: every bar at x == 0 is dark cyan.
s.Colors = xs.Select(x => x == 0 ? darkCyan : planeColor).ToArray();
});See Advanced for how the library composites several 3-D series by depth.
Applies to every 3-D series:
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Surface3D(x, y, z)
.SetXLabel("x").SetYLabel("y").SetZLabel("sin(r)/r")
.SetZLim(-0.3, 1.0)
.WithCamera(elevation: 35, azimuth: -55, distance: 8)
.WithLighting(ambient: 0.4, diffuse: 0.6, dirX: 1.0)
.With3DRotation())
.Save("surface3d.svg");distance controls perspective (orthographic when omitted). SetZLabel/SetZLim write to Axes.ZAxis (Axis3D), like SetXLabel/SetYLabel on 2-D charts. With3DRotation() embeds JS for mouse-drag and keyboard rotation (arrow keys + Home reset).
Builder placement (v1.1.4+) — call
WithCamera/WithLightinginside theAddSubPlot(..., ax => ax...)lambda. At thePlt.Create().WithCamera(...)level they only configure the unused figure-level default axes on multi-subplot figures.

Draws a 3-D polyline through arbitrary (x, y, z) points. It is depth-sorted and projected through Projection3D.
double[] t = Enumerable.Range(0, 200).Select(i => i * 0.1).ToArray();
double[] x = t.Select(v => Math.Cos(v)).ToArray();
double[] y = t.Select(v => Math.Sin(v)).ToArray();
double[] z = t;
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Plot3D(x, y, z, s => { s.Color = Colors.Blue; s.Label = "Helix"; })
.WithCamera(elevation: 25, azimuth: -60)
.With3DRotation())
.Save("line3d.svg");
Triangulates unstructured (x, y, z) point clouds with Delaunay triangulation. Each face is depth-sorted, then shaded with Vec3.FaceNormal and Color.Shade().
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Trisurf(x, y, z, s => { s.Color = Colors.Teal; s.Alpha = 0.8; })
.WithCamera(elevation: 30, azimuth: -45)
.With3DRotation())
.Save("trisurf.svg");
Computes contour lines on a grid via marching squares and projects them into 3-D space at their Z level.
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Contour3D(x, y, z, s => { s.Levels = 10; })
.WithCamera(elevation: 35, azimuth: -55)
.With3DRotation())
.Save("contour3d.svg");
Draws arrows in 3-D space, each with a shaft and a cone head. Use it for electromagnetic fields, fluid flow, or gradient vectors.
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Quiver3D(x, y, z, u, v, w, s => { s.Color = Colors.Red; s.ArrowLength = 0.3; })
.WithCamera(elevation: 25, azimuth: -60)
.With3DRotation())
.Save("quiver3d.svg");
Renders a bool[,,] voxel grid as face-culled cubes; adjacent filled voxels suppress shared faces. Visible faces are depth-sorted through DepthQueue3D.
var filled = new bool[5, 5, 5];
// fill some voxels...
filled[2, 2, 2] = true;
filled[2, 2, 3] = true;
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Voxels(filled, s => { s.Color = Colors.Orange; s.Alpha = 0.8; })
.WithCamera(elevation: 30, azimuth: -50)
.With3DRotation())
.Save("voxels.svg");
Places text labels at arbitrary (x, y, z) positions, projected to 2-D pixel space.
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Surface3D(x, y, z)
.Text3D(0, 0, 1.0, "Peak", s => { s.Color = Colors.Red; })
.WithCamera(elevation: 35, azimuth: -55)
.With3DRotation())
.Save("text3d.svg");double[] r = [1, 2, 3, 4, 5];
double[] theta = Enumerable.Range(0, 5).Select(i => i * Math.PI / 2.5).ToArray();
Plt.Create().PolarPlot(r, theta).Save("polar_line.svg");
Plt.Create().PolarScatter(r, theta).Save("polar_scatter.svg");
// Wind rose / radar bar
double[] speeds = [5, 10, 8, 3, 7, 12, 6, 9];
double[] dirs = Enumerable.Range(0, 8).Select(i => i * Math.PI / 4).ToArray();
Plt.Create().PolarBar(speeds, dirs, b => b.BarWidth = 0.7).Save("windrose.svg");var tree = new TreeNode
{
Label = "Revenue",
Children =
[
new TreeNode { Label = "Products", Value = 400 },
new TreeNode { Label = "Services", Value = 300 },
new TreeNode { Label = "Licensing", Value = 200 }
]
};
Plt.Create().Treemap(tree).Save("treemap.svg");
Plt.Create().Sunburst(tree).Save("sunburst.svg");Draws hierarchical clustering as the usual "U"-shaped segments. Each internal node is a merge, and its vertical position is the merge distance (TreeNode.Value). This follows the convention of SciPy's scipy.cluster.hierarchy.dendrogram.
Plt.Create()
.Dendrogram(tree, s =>
{
s.Orientation = DendrogramOrientation.Top; // Top, Bottom, Left, Right
s.CutHeight = 1.5; // dashed reference line + cluster colours
s.CutLineColor = Colors.Red;
s.ColorByCluster = true; // qualitative IColorMap (default Tab10)
})
.Save("dendrogram.svg");CutHeight uses strict less-than (node.Value < cut), which matches SciPy's color_threshold convention. A single-leaf tree renders the lone label at the centre of the plot. If every merge distance is zero, the drawing collapses to the leaf baseline; a unit maxMerge fallback avoids a division by zero. See the cookbook entry.
Combines a heatmap with optional row and column dendrograms in one subplot, the equivalent of seaborn's sns.clustermap. When you supply trees, the rows and columns are reordered to match the dendrogram leaf order, so the cells line up with the tree.
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.Clustermap(data, s =>
{
s.RowTree = rowTree; // leaf Value = original row index
s.ColumnTree = colTree; // leaf Value = original column index
s.RowDendrogramWidth = 0.15; // fraction of width, default 0.15, clamped [0, 0.9]
s.ColumnDendrogramHeight = 0.15;
s.ColorMap = ColorMaps.RdBu;
s.ShowLabels = true;
s.LabelFormat = "F2";
}))
.ToSvg();A leaf's TreeNode.Value must be the zero-based index of the original row or column, and internal nodes carry the merge distance. A malformed tree falls back to the identity order without reporting an error. See the cookbook entry.
Builds an N×N matrix of subplots from N variables, the equivalent of seaborn's pairplot. Diagonal cells show the distribution of one variable, as a histogram or a KDE. Off-diagonal cells show (i, j) scatters of two variables, optionally coloured by hue group.
double[][] vars = [petalLength, petalWidth, sepalLength, sepalWidth];
int[] hue = species.Select(s => (int)s).ToArray();
string[] hLab = ["Setosa", "Versicolor", "Virginica"];
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.PairGrid(vars, s =>
{
s.Labels = ["Petal L", "Petal W", "Sepal L", "Sepal W"];
s.HueGroups = hue;
s.HueLabels = hLab;
s.DiagonalKind = PairGridDiagonalKind.Kde; // Histogram (default), Kde, None
s.Triangular = PairGridTriangle.LowerOnly; // Both (default), LowerOnly, UpperOnly
}))
.ToSvg();Diagonal histograms stack one per hue group at Alpha = 0.6, and KDE draws one curve per group. Off the diagonal, the dot radius follows MarkerSize. OffDiagonalKind = None shows the diagonal marginals only. = Hexbin draws density grids for exploratory analysis of high-cardinality data, and ignores hue (v1.10). CellSpacing defaults to 0.02 and is clamped to [0, 0.2]. See the cookbook entry.

Steady pictures (v1.7.2 Phase W). The interactive view starts pixel-identical to the static SVG: every node at every depth is visible on first paint. Children paint over parents, so where rectangles overlap you see the deepest visible label. Clicking a parent rectangle collapses its subtree; click again to restore it. The visibility model walks the ancestry, so a collapsed subtree keeps its descendants' own collapse state. Multiple subtrees collapse independently. Leaves are not clickable.
The drilldown is keyboard-accessible through tabindex="0" and ARIA roles. Calling WithTreemapDrilldown() embeds the interactive script. Every rect is tagged with data-treemap-node / -depth / -parent, so the script can navigate without re-parsing. For static SVG output with deep trees, call .WithAutoSize(root) so the canvas fits every label.
Plt.Create()
.WithTreemapDrilldown()
.AddSubPlot(1, 1, 1, ax => ax.Treemap(catalogue, s => s.ShowLabels = true))
.Save("treemap_drilldown.svg");Draws indented rows with right-aligned numeric columns, the shape of htop's process tree. The ARIA spec has a
role for it (treegrid); a treemap has no role at all.
Why it sits beside the treemap instead of replacing it. A treemap shows at a glance which items are big, and it stops there. NN/g puts its useful depth at two or three levels, and a rectangle too small for its label has to fall back to a tooltip. Measured on a live fleet, two of the twenty-three lanes carried every message in an hour. Drawn as area, that is two rectangles and twenty-one slivers. Drawn as rows, it is a column of numbers you can compare exactly. Use the treemap when the question is how a whole divides up. Use the tree grid when you need to compare values.
TreeGridRow[] rows =
[
new("Cortex.Ingest", ["56.0%", "1450 MB", "34"]) { Depth = 0, Expanded = true, Url = "/?process=cortex" },
new("obs-ingest", ["31.2%", "—", "12"]) { Depth = 1 },
new("metrics-fold", ["18.7%", "—", "9"]) { Depth = 1, Accent = Colors.Orange },
new("Ldr.Binance", ["46.0%", "920 MB", "27"]) { Depth = 0, Expanded = false, Url = "/?process=binance" },
];
Plt.Create()
.AddSubPlot(1, 1, 1, ax =>
{
ax.TreeGrid(rows, g =>
{
g.ColumnHeaders = ["load", "working set", "threads"];
g.RowHeight = 22;
g.IndentWidth = 18;
});
ax.HideAllAxes();
})
.Save("tree_grid.svg");The caller flattens the tree. Only the caller knows which subtrees are open, so the series draws the rows it
is handed and nothing else. A row's Url wraps that row in an SVG <a href>, so expanding a subtree is a link
to another URL and needs no script. That is also what makes the control-room descent keyboard-reachable.
Expanded drives the chevron (▾ open, ▸ closed) and the row's aria-expanded; null marks a leaf, which has
nothing to disclose. Depth becomes aria-level, which gives the grid a navigable structure rather than a
purely visual one. Accent sets the colour of a row's text. Use it only where a value deviates, which is the
rule for colour everywhere else on a wall display.
The grid carries no data of its own and adds nothing to the axes range. It fills the region it is given.

Renders a two-level TreeNode as an inner pie with an outer breakdown ring. It wraps Sunburst(...) with InnerRadius = 0.
var departments = new TreeNode
{
Label = "Revenue",
Children = [
new() { Label = "Electronics", Children = [
new() { Label = "Phones", Value = 40 },
new() { Label = "Laptops", Value = 35 },
new() { Label = "Audio", Value = 25 }] },
/* more departments... */
]
};
Plt.Create()
.WithTitle("Revenue by department and product")
.AddSubPlot(1, 1, 1, ax => ax.NestedPie(departments))
.Save("nested_pie.svg");Draws nodes and edges in 2D. Use it for correlation networks (Pearson edge weights), lead-lag flow (TransferEntropy directed edges), Louvain community visualisation (node colour is the community ID), and minimum spanning trees. Three deterministic layouts ship in v1.10 PR 1: Manual, Circular and Hierarchical. ForceDirected (Fruchterman–Reingold) follows in PR 2.
GraphNode[] nodes =
[
new("AAPL", ColorScalar: 0.2),
new("MSFT", ColorScalar: 0.2),
new("GOOG", ColorScalar: 0.7),
];
GraphEdge[] edges =
[
new("AAPL", "MSFT", Weight: 0.85),
new("AAPL", "GOOG", Weight: 0.42, IsDirected: true),
new("MSFT", "GOOG", Weight: 0.55, IsDirected: true),
];
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.NetworkGraph(nodes, edges, s =>
{
s.Layout = GraphLayout.Circular; // Manual / Circular / Hierarchical
s.ColorMap = ColorMaps.Viridis;
s.ShowNodeLabels = true;
s.NodeRadiusScale = 8.0;
}))
.ToSvg();An edge with IsDirected gets an arrowhead at the target end (ArrowHeadBuilder.FancyArrow). EdgeThicknessScale multiplies each edge's Weight to give the stroke width, and NodeRadiusScale multiplies each node's SizeScalar to give the circle radius. There is a DataFrame extension too: df.NetworkGraph("source", "target", weightCol: "weight", directedCol: "directed") derives the nodes from the union of the source and target columns.
Plots a JdK-style 2D scatter of (RS-Ratio, RS-Momentum) for each asset against a benchmark. Each asset gets a fading tail, and a 100/100 quadrant grid splits the plot into Leading, Weakening, Lagging and Improving. There are three RS formulas: DualEma (the default, for trending markets), ZScore (for mean reversion) and LogReturn (for high volatility). The series arrived in v1.11.0, and overlay support in v1.11.2.
// Basic — three alts vs BTC benchmark
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.SetXLabel("RS-Ratio")
.SetYLabel("RS-Momentum")
.RelativeRotation(
new[] { ethCloses, bnbCloses, solCloses },
btcCloses,
new[] { "ETH", "BNB", "SOL" },
s => { s.TailLength = 12; s.ColorMap = ColorMaps.Plasma; }))
.ToSvg();
// With absorption + ENB overlays (Layer 3 → Layer 2 feedback)
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.RelativeRotation(assetCloses, bench, labels, s =>
{
s.AbsorptionRatioPerBar = absorptionPerBar; // double[] [0..1], green→red fill
s.EnbPerBar = enbPerBar; // double[], radius ∝ ENB
}))
.ToSvg();See RelativeRotationSeries for the full property reference, formula details, and minimum bar requirements.

The v1.1.4 renderer runs an 8-step pipeline: column assignment, node alignment, iterative vertical relaxation, gradient link colour modes, sub-labels, inside-labels, hover emphasis and vertical orientation. The sample gallery is in images/sankey_*.png:
-
sankey_process_distribution.svg— a 5-column process cascade with tonnage sub-labels, gradient links and hover emphasis -
sankey_income_statement.svg— the J&J Q1 FY25 income statement, with green and red node colouring and "+2% Y/Y" sub-label deltas -
sankey_customer_journey.svg— a 4-timestep alluvial diagram whereHomereappears across columns, pinned with an explicitSankeyNode.Column -
sankey_un_expenses.svg— a 2-column baseline with outside labels on aHideAllAxes()canvas -
sankey_severity_cascade.svg— 4-column patient severity transitions with 24 relaxation iterations -
sankey_vertical.svg— a vertical (top-to-bottom) conversion funnel
Minimal example:
SankeyNode[] nodes = [
new("Coal", Color.FromHex("#6B8E23")),
new("Gas", Color.FromHex("#4682B4")),
new("Electricity", Color.FromHex("#FFD700"), SubLabel: "80 TWh"),
new("Heat", Color.FromHex("#D2691E"), SubLabel: "40 TWh")];
SankeyLink[] links = [new(0, 2, 50), new(1, 2, 30), new(1, 3, 20)];
Plt.Create()
.WithSankeyHover() // v1.1.4 hover emphasis script
.AddSubPlot(1, 1, 1, ax => ax
.HideAllAxes()
.Sankey(nodes, links, s =>
{
s.NodeWidth = 24;
s.Iterations = 20; // vertical relaxation passes
s.LinkColorMode = SankeyLinkColorMode.Gradient;
s.Orient = SankeyOrientation.Horizontal; // or Vertical
}))
.Save("sankey.svg");Key properties (v1.1.4):
| Property | Meaning |
|---|---|
SankeyNode.SubLabel / SubLabelColor
|
A secondary label drawn one line below at 80 % font size. Useful for metric and delta pairs such as "$13.9B +2%" |
SankeyNode.Column |
Overrides the computed column. It pins the node to a semantic column whatever the BFS topology says. Use it for alluvial and time-step Sankeys |
SankeySeries.NodeAlignment |
Justify (default) / Left / Right / Center — matches D3 sankey* modes |
SankeySeries.Iterations |
The number of vertical relaxation passes; the default is 6. Use more for denser topologies |
SankeySeries.LinkColorMode |
Source / Target / Gradient (default). Gradient emits an SVG <linearGradient> per link. |
SankeySeries.Orient |
Horizontal (default) / Vertical — top-to-bottom flow |
SankeySeries.InsideLabels |
Draw labels centred inside the node rect (when NodeWidth is wide enough) |
Plt.Create().Quiver(x, y, u, v).Save("quiver.svg");
Plt.Create().Barbs(x, y, u, v).Save("barbs.svg");
Plt.Create().Streamplot(x, y, u, v).Save("stream.svg");
Plt.Create().Radar(categories, values).Save("radar.svg");
Plt.Create().Waterfall(labels, deltas).Save("waterfall.svg");
Plt.Create().Funnel(labels, values).Save("funnel.svg");
Plt.Create().Gauge(value: 0.72, min: 0, max: 1).Save("gauge.svg");
Plt.Create().ProgressBar(value: 0.65).Save("progress.svg");
Plt.Create().Sparkline(timeSeries).Save("sparkline.svg");
Plt.Create().Table(headers, rows).Save("table.svg");
Plt.Create().Spectrogram(signal, sampleRate: 44100).Save("spec.svg");
Plt.Create().BrokenBar(yranges, xranges).Save("brokenbar.svg");The control room is one screen for an operator. It holds KPI tiles, state timelines, and a shared trend panel.
All of them use one time window that the caller supplies; the library never reads the wall clock. The 1.14 line is
built around this screen. Plt.OpsDashboard() arrived with it, and the releases since then add fixes and
extensions for running the screen live.
The picture is the output of the code below, at 1200×630.
Plt.OpsDashboard()
.WithTitle("Fleet — control room")
.WithWindow(now, TimeSpan.FromMinutes(5))
.WithNormalBand(0, 250) // the band the trend panel is judged against
// A plain reading: no threshold, just the count and what it is out of.
.AddTile(9, t => { t.Label = "Processes"; t.Format = "0"; t.Caption = "of 9 · 25 lanes"; })
// A cost per message, inside its threshold. The caption carries the DENOMINATOR the number
// was measured over — 3.8 µs over what? — and the sparkline shows which way it is moving.
.AddTile(3.8, t =>
{
t.Label = "Nexus (µs/msg)";
t.Format = "0.0";
t.Target = 6; // the comparative: a number alone cannot be judged
t.Caption = "threshold 6" + Environment.NewLine + "2 148 msg · 1 s";
t.Trend = decideHistory; // inline Tufte sparkline: no axis, no frame, no ticks
})
// The same tile, breached: the caption states the overshoot and colour appears — ONLY because
// it is out of band, and it comes from the theme's alarm palette, never the series cycle.
.AddTile(312, t =>
{
t.Label = "Bus latency (µs)";
t.Format = "0";
t.Target = 250;
t.Caption = "threshold 250 · +62" + Environment.NewLine + "2 148 msg · 1 s";
t.Trend = latencyTail;
t.AccentColor = Theme.OpsNight.Alarm.Warning;
})
// A loss counter that is SUPPOSED to read zero. A measured zero is a real answer.
.AddTile(0, t => { t.Label = "Sent offline (msg)"; t.Format = "0"; t.Target = 0; t.Caption = "threshold 0"; })
// No signal: the hatch says "no information", and the format's ZERO section prints a dash, so the
// tile never shows a number it did not measure.
.AddTile(0, t =>
{
t.Label = "Exchange feed";
t.Format = "0.0;-0.0;—";
t.Caption = "no signal";
t.Hatch = HatchPattern.ForwardDiagonal;
})
.AddTimeline(services, l => l.Label = "Bus") // up / degraded / no data / up
.AddTrend(clock, latency, s => s.Label = "latency µs")
.Build()
.WithTheme(Theme.OpsNight)
.Build()
.SaveSvg("control_room.svg");| Property | What it does |
|---|---|
Format |
the number format for the headline. With three sections (positive;negative;zero), an unknown reading prints a dash instead of a zero that was never measured |
Target |
the value the reader judges the headline against. Without one, a reading such as "68" is neither good nor bad |
Caption |
the line or lines under the label: the threshold, and what the number was measured over. Newlines stack the lines; a line wider than the tile wraps at word boundaries |
Trend |
an inline sparkline inside the tile. It has no axis, no frame and no ticks, and it never changes the headline's scale |
Hatch |
marks the tile as having no information. It draws a pattern, never a colour, because a source with no data is different from a broken one |
AccentColor |
sets a colour. Use it only when something is wrong. Take it from Theme.Alarm so it never collides with a series colour |
Condition |
what the reading is in, on two axes. The tile then takes the colour and the pattern by itself, so the mapping is written once instead of at every call site |
A number is not a state. Your application decides what counts as bad — the library holds no opinion about when something is broken — and hands the tile the verdict:
.AddTile(p99, t =>
{
t.Label = "RFx p99";
t.Condition = p99 > 30 ? new OpsCondition(OpsSeverity.Critical)
: p99 > 25 ? new OpsCondition(OpsSeverity.Warning)
: OpsCondition.Resting;
})The condition answers two separate questions:
| Axis | Values | What it says |
|---|---|---|
OpsSeverity |
Normal, Warning, Critical
|
how bad the reading is. The only axis that compares |
OpsVisibility |
Observed, Unknown, Shelved
|
whether the reading can be believed, and whether someone silenced it |
A silent source is not a degraded one and a muted alarm is not a solved problem, so neither sits on the severity
ladder. Normal wears no colour; Warning and Critical take the theme's alarm colours; Unknown and
Shelved each wear their own pattern, so an operator can tell at a glance which of the two is on the wall. A
timeline band takes the same condition.
OpsCondition.RollUp(children) reads a group the way an operator does: the worst child that can actually be
seen decides the colour, while the unseen and the muted are counted rather than ranked. A shelved child leaves
the colour alone but never leaves the screen, and a parent whose children have all gone quiet is itself unseen.
.WithEventMarker(deployedAt, m => m.Label = "deploy 1.4.2")One vertical line on the trend and on every timeline — every panel that has a clock. Set it once, so a reader comparing two panels can trust that the same line means the same instant. The tile row and the topology panel have no clock and never receive it.
A threshold line marks which side counts as a breach. The legend shows the live reading beside the series name.
Plt.Create()
.Plot(hours, load, s => s.Label = "CPU load %")
.Threshold(80.0, Orientation.Horizontal, ThresholdBreach.Above, color: Colors.Red, label: "Alarm")
.SetXLabel("Hour")
.SetYLabel("Load %")
.WithLegend() // NOTE: WithLegend replaces the legend record wholesale — call it BEFORE
.WithLegendValues() // WithLegendValues, or the values silently switch off again
.Save("load.svg");| Piece | What it is |
|---|---|
StatTileSeries |
the tile above: headline, label, caption, Target, inline Trend, Hatch
|
StateSegment timeline |
one band per state over the window; a gap state means "no information", the same as a hatched tile |
BulletGraphSeries |
Stephen Few's design: a bar with a target and bands. Use it instead of a radial gauge |
Theme.Alarm / AlarmPalette
|
a theme names its Resting / Warning / Critical / Unknown colours |
OpsNight / OpsPanel / OpsWarm / OpsContrast
|
operator backgrounds for a wall display; they are not meant for a report |
See Styling#operator-themes-and-the-alarm-palette for the palette and the themes.
dotnet run --project Samples/MatPlotLibNet.Samples.ControlRoomThis is a separate sample project, because it is a reference implementation of a whole screen rather than an example of one control. It simulates a 15-bus federation with its own domain (a bus holds processes, a process holds lanes, plus alarm conditioning and a staleness clock) and a background simulator that keeps running whether or not a browser is looking.
The screen descends from fleet to bus to process to lanes, and no level is ever replaced by the next one. The level you leave becomes the rail on the left, still coloured, so a sibling is one click away and you never lose sight of what stands next to the thing you are reading. That is the piece three earlier attempts were missing. Each of them drew one level well and then had no way to go further.
There are two gestures, and you need both:
| gesture | what it does |
|---|---|
| click a block | drills down one level in the hierarchy |
| click the max or min in the strip | drills through: it leaves the aggregate and opens the member that produced the value (the exemplar). That is the purpose of making an aggregate clickable |
At the bottom level neither gesture opens anything, because a link that leads nowhere is worse than no link at all.
A block is one size at every level and at every count: a fixed track, never a fraction of the row. Two buses are two blocks with an empty row beside them. Stretching those two blocks across the whole row would make the fleet look wider than it is, so the empty space is part of what the reader sees.
Lanes are drawn as rows rather than cards, because they are the bottom level and they answer a different question. You ask a bus or a process how hard it is working, and load answers that. You ask a lane whether it is keeping up, which load cannot answer at all: a lane that has stopped delivering burns no load. So a lane row carries backlog, latency and errors with a status chip.
The whole state lives in the URL (?bus=, ?process=). It survives every redraw, every block is an anchor so
the descent is keyboard-reachable with no script, and you can paste it to a colleague during an incident.
This package adds 13 map projections, GeoPolygonSeries (which renders any closed ring as a styled polygon: choropleth, ocean fill, land fill or a custom shape), and embedded Natural Earth 110m data (coastlines and countries, with no external download).
using MatPlotLibNet.Geo.Projections;
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.WithProjection(new Mercator())
.Ocean(new Mercator(), new Color(180, 220, 255))
.Land(new Mercator(), new Color(240, 230, 200))
.Coastlines(new Mercator(), Colors.Black, lineWidth: 0.5)
.Borders(new Mercator(), Colors.Gray, lineWidth: 0.3))
.Save("worldmap.svg");| Family | Projection | Notes |
|---|---|---|
| Cylindrical | PlateCarree |
Equirectangular — the simplest 1:1 lat/lon mapping |
| Cylindrical | Mercator |
Conformal — preserves angles (web maps) |
| Cylindrical | TransverseMercator |
Mercator rotated 90° — UTM zones |
| Pseudo-cylindrical | Mollweide |
Equal-area; world maps |
| Pseudo-cylindrical | Robinson |
Compromise; common in atlases |
| Pseudo-cylindrical | Sinusoidal |
Equal-area; meridian-true |
| Pseudo-cylindrical | EqualEarth |
Modern equal-area (Šavrič et al. 2018) |
| Pseudo-cylindrical | NaturalEarthProjection |
Compromise; aesthetic for global view |
| Conic | AlbersEqualArea |
Equal-area conic — ideal for mid-latitude regions (US, Europe) |
| Conic | LambertConformal |
Conformal conic — aviation charts |
| Azimuthal | Orthographic |
"View from space" — globe rendering |
| Azimuthal | Stereographic |
Conformal azimuthal — polar regions |
| Azimuthal | AzimuthalEquidistant |
Distances from centre preserved |
All projections implement IGeoProjection. Forward(latitude, longitude) returns a ProjectedPoint and Inverse(x, y) returns a GeoCoordinate?, so a coordinate round-trips. Since v1.13.0 both are record structs instead of tuples: readonly record struct ProjectedPoint(double X, double Y) and readonly record struct GeoCoordinate(double Latitude, double Longitude). Call sites that deconstruct them are unaffected. Bounds returns GeoBounds(XMin, XMax, YMin, YMax), which is now a record struct as well and was a 4-tuple.
// Choropleth: colour countries by a metric.
var ax = Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.WithProjection(new Robinson()));
foreach (var (country, value) in countryData)
{
var poly = NaturalEarth110m.Countries.First(c => c.Properties["NAME"] == country);
ax.AddSeries(new GeoPolygonSeries(poly, new Robinson())
{
FillColor = ColorMaps.Viridis.GetColor(value / maxValue),
EdgeColor = Colors.White,
EdgeWidth = 0.3,
});
}The package bundles GeoJSON for the Natural Earth 110m cultural and physical datasets. There is no HTTP fetch, no download and no external dependency.
| Property | Contents |
|---|---|
NaturalEarth110m.Coastlines |
World coastline rings (134 features) |
NaturalEarth110m.Countries |
World countries with Properties["NAME"], ["ISO_A2"], etc. (177 features) |
.WithProjection(IGeoProjection) // Sets axes coordinate transform
.Coastlines(projection, color, lineWidth) // Adds Natural Earth 110m coastlines
.Borders(projection, color, lineWidth) // Adds Natural Earth 110m country borders
.Ocean(projection, color) // Fills entire map background
.Land(projection, color) // Fills land massesEach of these is built on GeoPolygonSeries, the lowest-level primitive, so you can mix them with your own GeoPolygonSeries calls in the same axes for choropleth or custom-shape rendering.
MatPlotLibNet 1.18.0












