Skip to content

Chart Types

H.P. Gansevoort edited this page Oct 31, 2026 · 34 revisions

Chart Types

Try it live: Open the Playground to experiment with charts in your browser. Browse the Cookbook (39 pages) for copy-paste code examples with rendered output.

MatPlotLibNet has 91 series types across 16 categories — four of them streaming — plus 13 map projections and maps (a choropleth, bubbles, flows, a cartogram) 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. Thirty-eight more charts, from a Pareto chart to a power spectrum, are built in one call from these series and are not series types of their own; see Charts in one call.


Gallery

The fidelity suite renders these charts and compares them pixel for pixel against matplotlib. Click a tile for the full-resolution PNG.

Line chart — KDE KDE (Gaussian kernel density) Violin plot Violin (3 groups) Box plot Box (4 groups)
Hexbin density Hexbin (2-D binning) Stem plot Stem (sampled signal) Waterfall chart Waterfall (finance)
Sankey diagram Sankey (flow) Radar chart Radar (5 axes) Streamplot Streamplot (vector field)
Polar scatter Polar scatter Treemap Treemap (squarified) Pie chart Pie (with labels)

Charts in one call

Thirty-eight charts are one call each on the axes builder. Each verb builds its chart from the series on this page, one series for each thing it draws, so they are not counted among the 91: a Pareto chart is a bar series and a line on a right-hand axis, a ridgeline one area for each ridge, a power spectrum one line. The verbs are extension methods in the namespace MatPlotLibNet, in six classes named for the data they take. Their last argument, configure, styles the series after the verb has set them up. Each one is shown with its picture on the examples pages (1.20.0, 1.22.0, 1.25.0, 1.26.0 and 1.28.0).

Data Class Verbs
values keyed by names CategoricalAxesExtensions Lollipop, Dumbbell, Pareto, SlopeGraph, DotPlot, ParallelCoordinates, Marimekko
a sample to estimate from DistributionAxesExtensions Ridgeline, QQPlot, ProbabilityPlot, KaplanMeier, RiskTable, Raincloud, StemAndLeaf
values over x positions XYAxesExtensions BumpChart, ControlChart, ShewhartChart, StreamGraph, Horizon, ConnectedScatter, VLines, HLines, Stairs, Triplot
the cells of a grid GridAxesExtensions CalendarHeatmap, Spy, MatShow, CalendarEvents
a series of prices in time order PriceAxesExtensions Renko, Kagi
a signal: samples taken at a steady rate, in the order they were taken SignalAxesExtensions Psd, Csd, Cohere, MagnitudeSpectrum, PhaseSpectrum, AngleSpectrum, Acorr, Xcorr

ProbabilityPlot(sample, reference) is QQPlot against the distribution a QQReference names (exponential, uniform, log-normal, Student t or your own quantile function), RiskTable(groups) writes the number at risk and the log-rank test under a KaplanMeier chart, and ShewhartChart(data, rules, limits) draws the moving-range, X-bar, R, p and c charts and flags Nelson's eight tests. StemAndLeaf(sample) writes every observation on a row, the stem and its leaves, each leaf in a column of its own, and ConnectedScatter(x, y) joins the points of a scatter with arrows that show the way the path runs. Stairs(values, edges) draws a value for each bin as a staircase, an outline or, with a baseline, a fill; VLines and HLines draw a segment at each position between two limits; and Triplot(x, y) draws the edges of a triangulation, the mesh that Tripcolor and Tricontour fill and contour.

A signal is neither x and y arrays nor a sample, because its order is its meaning, so the eight verbs of SignalAxesExtensions take a double[] of samples. Psd, Csd and Cohere estimate a power, a cross spectrum and a coherence by Welch's method from averaged segments, set by one SpectralOptions value (matplotlib's defaults for what it leaves out); MagnitudeSpectrum, PhaseSpectrum and AngleSpectrum take one transform of the whole signal, set by one SpectrumOptions value; Acorr and Xcorr correlate a signal with itself or with another, set by one CorrelationOptions value, and draw the correlation as stems or as markers. A value that is missing is a gap: a sample that is NaN leaves every segment or lag that holds it out, and nothing is drawn when none is left.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

string[] kinds = ["Dents", "Scratches", "Stains", "Cracks"];
double[] counts = [98, 142, 45, 67];
double[] lastYear = [104, 120, 51, 70];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Pareto(kinds, counts)).Save("pareto.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Lollipop(kinds, counts)).Save("lollipop.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Dumbbell(kinds, new BarGroup("Last year", lastYear), new BarGroup("This year", counts))).Save("dumbbell.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.DotPlot(kinds, [new BarGroup("This year", counts), new BarGroup("Last year", lastYear)])).Save("dots.svg");

The verbs of 1.28.0 take their data in the same way:

using MatPlotLibNet;

double[] edges = [0, 10, 20, 30, 40, 50];
double[] counts = [3, 8, 14, 9, 4];
double[] signal = [.. Enumerable.Range(0, 1000).Select(i => Math.Sin(2 * Math.PI * 50 * i / 1000.0))];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Stairs(counts, edges, 0)).Save("stairs.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Psd(signal, new SpectralOptions { SampleRate = 1000, SegmentLength = 200 })).Save("psd.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Acorr(signal, new CorrelationOptions { MaxLags = 60 })).Save("acorr.svg");

Written to JSON, a composed chart is its parts, so the MCP server and every transport carry it and read it back as the same picture; it is not sorted, estimated or laid out again.


Basic

Bar and line charts

Line — Plot()

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] x = [1, 2, 3, 4, 5];
double[] y = [2, 4, 3, 5, 4];

Plt.Create().Plot(x, y).Save("line.svg");
Plt.Create().Plot(x, y, s => { s.Color = Colors.Blue; s.LineWidth = 2; s.Label = "Series A"; }).Save("line.svg");

A line can carry its name at its end instead of in the legend: WithDirectLabel() after Plot on the axes builder. See the examples page.

Scatter — Scatter()

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] x = [1, 2, 3, 4, 5];
double[] y = [2, 4, 3, 5, 4];

Plt.Create().Scatter(x, y).Save("scatter.svg");
Plt.Create().Scatter(x, y, s => { s.Marker = MarkerStyle.Circle; s.MarkerSize = 8; }).Save("scatter.svg");

Bar — Bar()

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

Plt.Create().Bar(["Q1", "Q2", "Q3", "Q4"], [100, 200, 150, 250]).Save("bar.svg");

// Horizontal: the values run along X and the names stand on the Y axis, the first at the bottom as matplotlib's barh puts it.
Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.Bar(["Q1", "Q2", "Q3", "Q4"], [100, 200, 150, 250], s => s.Orientation = BarOrientation.Horizontal))
    .Save("barh.svg");

A bar series and an area series take a GradientColor: the fill runs from the series' colour to that colour, along a bar from its foot to its end and across an area from its baseline to the point of its top edge furthest from it. A gradient colour with an alpha of its own fades to clear, and the SVG, PNG and PDF all paint it.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.Bar(["Q1", "Q2", "Q3", "Q4"], [100, 200, 150, 250], s => s.GradientColor = Color.FromHex("#C6DBEF")))
    .Save("bar_gradient.svg");

Four bars in a deep blue at their foot and a pale blue at their end, beside an orange area that fades to clear toward its peak

Histogram — Hist()

using MatPlotLibNet;

var random = new Random(42);
double[] data = [.. Enumerable.Range(0, 500).Select(_ => random.NextDouble() + random.NextDouble())];

Plt.Create().Hist(data, bins: 20).Save("hist.svg");

Pie / Donut

using MatPlotLibNet;

Plt.Create().Pie([40, 35, 25], ["A", "B", "C"]).Save("pie.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Donut([40, 35, 25], ["A", "B", "C"])).Save("donut.svg");

Step, Fill, Error Bars, Stem, Box, Violin

using MatPlotLibNet;

double[] x    = [1, 2, 3, 4, 5];
double[] y    = [2, 4, 3, 5, 4];
double[] y1   = [1, 3, 2, 4, 3];
double[] y2   = [3, 5, 4, 6, 5];
double[] yErr = [0.3, 0.4, 0.2, 0.5, 0.3];
double[][] groups = [[1, 2, 2, 3, 4], [2, 3, 3, 4, 6], [1, 1, 2, 5, 5]];

Plt.Create().Step(x, y).Save("step.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.FillBetween(x, y1, y2)).Save("fill.svg");   // fill between two lines
Plt.Create().ErrorBar(x, y, yErr, yErr).Save("errbar.svg");                        // error below, error above
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Stem(x, y)).Save("stem.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.BoxPlot(groups)).Save("box.svg");         // groups: double[][]
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Violin(groups)).Save("violin.svg");

A violin's width at a height is the sample's Gaussian kernel density there, with Silverman's bandwidth (the estimator Kde and Ridgeline use), scaled so that the widest point is the violin's width; a sample without spread is a spike.

SignalSeries / SignalXYSeries — High-Performance Large Datasets

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.

using MatPlotLibNet;

double[] samples     = [.. Enumerable.Range(0, 200_000).Select(i => (i % 441) / 441.0 - 0.5)];
double[] x           = [.. Enumerable.Range(0, 200_000).Select(i => i * 0.01 + (i % 7) * 0.001)];
double[] xNonUniform = x;
double[] y           = [.. Enumerable.Range(0, 200_000).Select(i => (i % 1000) / 1000.0)];

// 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");

Statistical

using MatPlotLibNet;

double[] data       = [1.2, 2.3, 2.1, 3.4, 2.8, 3.9, 4.1, 2.6, 3.3, 2.9];
double[][] groups   = [[1, 2, 2, 3, 4], [2, 3, 3, 4, 6], [1, 1, 2, 5, 5]];
double[][] events   = [[0.5, 1.2, 3.4], [0.8, 2.2, 2.9, 4.1]];
double[] x          = [1, 2, 3, 4, 5];
double[] y          = [2.1, 3.9, 6.2, 7.8, 10.1];
string[] categories = ["red", "blue", "red", "green", "blue", "red"];

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).Save("strip.svg");
Plt.Create().Swarmplot(groups).Save("swarm.svg");
Plt.Create().Pointplot(groups).Save("point.svg");
Plt.Create().Eventplot(events).Save("event.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Regression(x, y)).Save("regression.svg");   // with LeastSquares fit line
Plt.Create().Residplot(x, y).Save("residual.svg");                                    // residuals of that fit
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Countplot(categories)).Save("count.svg");

Financial

Financial dashboard

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.

Financial dashboard (3-panel)

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] open   = [100, 102, 101, 104, 106, 105, 108, 110, 109, 112, 111, 114, 116, 115, 118, 117, 120, 122, 121, 124, 126, 125];
double[] close  = [102, 101, 104, 106, 105, 108, 110, 109, 112, 111, 114, 116, 115, 118, 117, 120, 122, 121, 124, 126, 125, 128];
double[] high   = [.. open.Zip(close, (o, c) => Math.Max(o, c) + 1.5)];
double[] low    = [.. open.Zip(close, (o, c) => Math.Min(o, c) - 1.5)];
double[] volume = [.. Enumerable.Range(0, open.Length).Select(i => 1000.0 + 50 * (i % 5))];

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");

Candlestick

using MatPlotLibNet;

double[] open  = [100, 102, 101, 104, 106, 105, 108, 110, 109, 112, 111, 114, 116, 115, 118, 117, 120, 122, 121, 124, 126, 125];
double[] close = [102, 101, 104, 106, 105, 108, 110, 109, 112, 111, 114, 116, 115, 118, 117, 120, 122, 121, 124, 126, 125, 128];
double[] high  = [.. open.Zip(close, (o, c) => Math.Max(o, c) + 1.5)];
double[] low   = [.. open.Zip(close, (o, c) => Math.Min(o, c) - 1.5)];

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Candlestick(open, high, low, close)
        .Sma(period: 20)
        .WithLegend())
    .Save("candlestick.svg");

OHLC

using MatPlotLibNet;

double[] open  = [100, 102, 101, 104, 106];
double[] close = [102, 101, 104, 106, 105];
double[] high  = [103, 103, 105, 107, 107];
double[] low   = [99, 100, 100, 103, 104];

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.OhlcBar(open, high, low, close))
    .Save("ohlc.svg");

Point and Figure, Renko and Kagi

Three charts draw a price without a time axis, from the closes alone. PointAndFigure(prices, boxSize, reversal = 3) is a series that draws columns of boxes, an X where the price rose and an O where it fell; Renko(prices, brickSize) and Kagi(prices, reversal) are verbs of PriceAxesExtensions that build a staircase of bricks and one path that thickens and thins from the series the library already has. Point and Figure asks for SetAspect(1.0 / boxSize) to make its boxes square; Renko and Kagi carry no ticks on the X axis, since it is only an order. They are shown with their pictures on the examples page.

using MatPlotLibNet;

double[] closes =
[
    100, 102, 105, 103, 108, 112, 109, 107, 111, 115, 118, 114, 110, 106, 103,
    107, 111, 116, 121, 119, 115, 112, 117, 123, 128, 125, 121, 118, 122, 127,
];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.PointAndFigure(closes, 2).SetAspect(1.0 / 2)).Save("point_and_figure.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Renko(closes, 3)).Save("renko.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Kagi(closes, 4)).Save("kagi.svg");

Seven columns of green Xs that climb and red Os that fall back, one box of 2 high each


Grid / Field

Heatmap with colormap

Heatmap

using MatPlotLibNet;

var data = new double[10, 12];
for (var r = 0; r < 10; r++)
    for (var c = 0; c < 12; c++)
        data[r, c] = r * c;

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Heatmap(data)
        .WithColorMap("plasma")
        .WithColorBar(cb => cb with { Label = "Intensity" }))
    .Save("heatmap.svg");

Contour & Filled Contour

using MatPlotLibNet;

double[] x = [.. Enumerable.Range(0, 20).Select(i => i * 0.5)];
double[] y = [.. Enumerable.Range(0, 15).Select(i => i * 0.5)];
var z = new double[y.Length, x.Length];
for (var r = 0; r < y.Length; r++)
    for (var c = 0; c < x.Length; c++)
        z[r, c] = (x[c] - 5) * (x[c] - 5) + (y[r] - 3.5) * (y[r] - 3.5);

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Contour(x, y, z)).Save("contour.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Contourf(x, y, z)).Save("contourf.svg");

ShowLabels writes each level on its line. A label stands at the middle of its line, turned along the line and kept upright, in the colour of the line and on a patch the colour of the theme's background; a line shorter than 1.2 times the width of its label gets none, and LabelFormat and LabelFontSize set the number's format and size. The number is formatted the same in every culture.

Rings of contour lines in blue and red, each labelled with its level in the colour of the line, the labels turned along the lines

Others

using MatPlotLibNet;

var bitmap = new double[8, 8];
for (var r = 0; r < 8; r++)
    for (var c = 0; c < 8; c++)
        bitmap[r, c] = (r + c) % 2;

double[] x = [.. Enumerable.Range(0, 20).Select(i => i * 0.5)];
double[] y = [.. Enumerable.Range(0, 15).Select(i => i * 0.5)];
var z = new double[y.Length, x.Length];
for (var r = 0; r < y.Length; r++)
    for (var c = 0; c < x.Length; c++)
        z[r, c] = x[c] + y[r];

// Pcolormesh takes the cell EDGES: one more than the cells in each direction.
double[] xEdges = [.. Enumerable.Range(0, x.Length + 1).Select(i => i * 0.5 - 0.25)];
double[] yEdges = [.. Enumerable.Range(0, y.Length + 1).Select(i => i * 0.5 - 0.25)];

// Scattered points for the hexbin and the triangulated series.
double[] px = [0, 1, 2, 0, 1, 2, 0.5, 1.5];
double[] py = [0, 0, 0, 1, 1, 1, 2, 2];
double[] pz = [1, 2, 1, 2, 3, 2, 4, 4];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Image(bitmap)).Save("image.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Hexbin(px, py)).Save("hexbin.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Pcolormesh(xEdges, yEdges, z)).Save("pcolor.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Tricontour(px, py, pz)).Save("tricontour.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Tripcolor(px, py, pz)).Save("tripcolor.svg");

Triplot(px, py) draws the edges of the same triangulation, one line for every edge, each shared edge once, and takes triangles of your own as a flat list of point indices. See the examples page.


3-D

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.

using MatPlotLibNet;

// A grid for the surfaces: z[row, column] over y[row] and x[column].
double[] x = [.. Enumerable.Range(0, 10).Select(i => i * 0.5)];
double[] y = [.. Enumerable.Range(0, 10).Select(i => i * 0.5)];
var z = new double[y.Length, x.Length];
for (var r = 0; r < y.Length; r++)
    for (var c = 0; c < x.Length; c++)
        z[r, c] = x[c] * y[r] / 10;

// Points for the series that take one (x, y, z) per point.
double[] px = [0, 1, 2, 0, 1, 2, 0.5, 1.5];
double[] py = [0, 0, 0, 1, 1, 1, 2, 2];
double[] pz = [1, 2, 1, 2, 3, 2, 4, 4];
double[] heights = pz;
double[] u = [1, 0, 1, 0, 1, 0, 1, 0];
double[] v = [0, 1, 0, 1, 0, 1, 0, 1];
double[] w = [1, 1, 1, 1, 1, 1, 1, 1];
var filled = new bool[3, 3, 3];
filled[1, 1, 1] = true;

// Original 6
Plt.Create().Surface(x, y, z).Save("surface.svg");
Plt.Create().Wireframe(x, y, z).Save("wireframe.svg");
Plt.Create().Scatter3D(px, py, pz).Save("scatter3d.svg");
Plt.Create().Bar3D(px, py, heights).Save("bar3d.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.PlanarBar3D(px, py, heights)).Save("planar_bars.svg");
Plt.Create().Stem3D(px, py, pz).Save("stem3d.svg");

// v1.3.0 — 6 new series
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Plot3D(px, py, pz)).Save("line3d.svg");             // projected polyline
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Trisurf(px, py, pz)).Save("trisurf.svg");           // Delaunay triangulated surface
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Contour3D(x, y, z)).Save("contour3d.svg");          // marching-squares contour lines
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Quiver3D(px, py, pz, u, v, w)).Save("quiver3d.svg"); // 3D vector field
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Voxels(filled)).Save("voxels.svg");                  // face-culled cubes
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Text3D(1, 1, 2, "label")).Save("text3d.svg");       // 3D annotation

Bar3D — solid cuboid bars

Bar3D single series

This is the equivalent of matplotlib's ax.bar3d(...). It draws rectangular prisms with matplotlib-exact per-face shading (ambient + diffuse·(dot(n̂, l̂) + 1)/2, which is 0.65 + 0.35·dot(n̂, l̂) at the defaults), depth-sorted across all bars so occlusion is correct at any camera angle. Multiple Bar3D calls on one axes stack into a grid:

Bar3D grouped — 5 rows

using MatPlotLibNet;
using MatPlotLibNet.Styling;

// One plane per Y, each with its own colour.
var rows = new Dictionary<double, Color>
{
    [0] = Colors.Red, [1] = Colors.Green, [2] = Colors.Blue, [3] = Colors.Cyan, [4] = Colors.Gold,
};
double[] xs = [.. Enumerable.Range(0, 20).Select(i => (double)i)];

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 = [.. xs.Select(x => 1 + (x + y) % 5)];
            ax.Bar3D(xs, ys, zs, s => { s.Color = color; s.BarWidth = 0.4; });
        }
    })
    .Save("bar3d_grouped.svg");

PlanarBar3D — flat translucent 3-D bars

PlanarBar3D skyscraper

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.

It reuses the rows and xs of the grouped example above:

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 = [.. xs.Select(x => 1 + (x + y) % 5)];
            ax.PlanarBar3D(xs, ys, zs, s =>
            {
                s.Color    = color;
                s.BarWidth = 0.8;
                s.Alpha    = 0.8;   // translucency
            });
        }
    })
    .Save("planar_bars.svg");

Per-bar colour override via Colors[]

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 = planeColor on each series
  • Per X — set s.Colors = xs.Select(x => lookup(x)).ToArray()
  • Combined — set both: Colors[i] wins where defined, Color is the fallback

PlanarBar3D x=0 highlight

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] xs = [-2, -1, 0, 1, 2];
double[] ys = [1, 1, 1, 1, 1];
double[] zs = [3, 4, 5, 4, 3];
var planeColor = Colors.Gold;
var darkCyan = Color.FromHex("#008B8B");

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => 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();
    }))
    .Save("planar_bars_x0_highlight.svg");

See Advanced for how the library composites several 3-D series by depth.

Camera, lighting, and rotation

Applies to every 3-D series:

using MatPlotLibNet;

double[] x = [.. Enumerable.Range(0, 41).Select(i => -8 + i * 0.4)];
double[] y = x;
var z = new double[y.Length, x.Length];
for (var r = 0; r < y.Length; r++)
    for (var c = 0; c < x.Length; c++)
    {
        var radius = Math.Sqrt(x[c] * x[c] + y[r] * y[r]);
        z[r, c] = radius == 0 ? 1 : Math.Sin(radius) / radius;
    }

Plt.Create()
    .With3DRotation()
    .AddSubPlot(1, 1, 1, ax => ax
        .Surface(x, y, z)
        .SetXLabel("x").SetYLabel("y").SetZLabel("sin(r)/r")
        .SetZLim(-0.3, 1.0)
        .WithCamera(elevation: 35, azimuth: -55, distance: 8)
        .WithLighting(dx: 1.0, dy: 0.0, dz: 0.5, ambient: 0.4, diffuse: 0.6))
    .Save("surface3d.svg");

distance controls perspective (orthographic when omitted). SetZLabel/SetZLim write to Axes.ZAxis (Axis3D), like SetXLabel/SetYLabel on 2-D charts. With3DRotation() is called on the figure builder and embeds JS for mouse-drag and keyboard rotation (arrow keys + Home reset).

Builder placement (v1.1.4+) — call WithCamera / WithLighting inside the AddSubPlot(..., ax => ax...) lambda. At the Plt.Create().WithCamera(...) level they only configure the unused figure-level default axes on multi-subplot figures.

Line3D — projected polyline (v1.3.0)

Line3D helix

Draws a 3-D polyline through arbitrary (x, y, z) points. It is depth-sorted and projected through Projection3D.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

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()
    .With3DRotation()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot3D(x, y, z, s => { s.Color = Colors.Blue; s.Label = "Helix"; })
        .WithCamera(elevation: 25, azimuth: -60))
    .Save("line3d.svg");

Trisurf3D — Delaunay triangulated surface (v1.3.0)

Trisurf3D

Triangulates unstructured (x, y, z) point clouds with Delaunay triangulation. Each face is depth-sorted, then shaded with Vec3.FaceNormal and Color.Shade().

using MatPlotLibNet;
using MatPlotLibNet.Styling;

// An unstructured cloud: 60 points on a spiral, z = x·y.
double[] x = [.. Enumerable.Range(0, 60).Select(i => i * 0.05 * Math.Cos(i * 0.5))];
double[] y = [.. Enumerable.Range(0, 60).Select(i => i * 0.05 * Math.Sin(i * 0.5))];
double[] z = [.. x.Zip(y, (a, b) => a * b)];

Plt.Create()
    .With3DRotation()
    .AddSubPlot(1, 1, 1, ax => ax
        .Trisurf(x, y, z, s => { s.Color = Colors.Teal; s.Alpha = 0.8; })
        .WithCamera(elevation: 30, azimuth: -45))
    .Save("trisurf.svg");

Contour3D — 3-D contour lines (v1.3.0)

Contour3D

Computes contour lines on a grid via marching squares and projects them into 3-D space at their Z level.

using MatPlotLibNet;

double[] x = [.. Enumerable.Range(0, 30).Select(i => -3 + i * 0.2)];
double[] y = x;
var z = new double[y.Length, x.Length];
for (var r = 0; r < y.Length; r++)
    for (var c = 0; c < x.Length; c++)
        z[r, c] = Math.Exp(-(x[c] * x[c] + y[r] * y[r]) / 2);

Plt.Create()
    .With3DRotation()
    .AddSubPlot(1, 1, 1, ax => ax
        .Contour3D(x, y, z, s => { s.Levels = 10; })
        .WithCamera(elevation: 35, azimuth: -55))
    .Save("contour3d.svg");

Quiver3D — 3-D vector field (v1.3.0)

Quiver3D

Draws arrows in 3-D space, each with a shaft and a cone head. Use it for electromagnetic fields, fluid flow, or gradient vectors.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

// A swirl: at each point on a 4×4 grid in the z = 0 plane, an arrow turning about the z axis and rising.
double[] x = [.. Enumerable.Range(0, 16).Select(i => (double)(i % 4))];
double[] y = [.. Enumerable.Range(0, 16).Select(i => (double)(i / 4))];
double[] z = new double[16];
double[] u = [.. y.Select(b => -(b - 1.5))];
double[] v = [.. x.Select(a => a - 1.5)];
double[] w = [.. Enumerable.Repeat(0.5, 16)];

Plt.Create()
    .With3DRotation()
    .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))
    .Save("quiver3d.svg");

Voxels — volumetric cubes (v1.3.0)

Voxels

Renders a bool[,,] voxel grid as face-culled cubes; adjacent filled voxels suppress shared faces. Visible faces are depth-sorted through DepthQueue3D.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

var filled = new bool[5, 5, 5];
// fill some voxels...
filled[2, 2, 2] = true;
filled[2, 2, 3] = true;

Plt.Create()
    .With3DRotation()
    .AddSubPlot(1, 1, 1, ax => ax
        .Voxels(filled, s => { s.Color = Colors.Orange; s.Alpha = 0.8; })
        .WithCamera(elevation: 30, azimuth: -50))
    .Save("voxels.svg");

Text3D — 3-D annotations (v1.3.0)

Text3D

Places text labels at arbitrary (x, y, z) positions, projected to 2-D pixel space.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] x = [.. Enumerable.Range(0, 21).Select(i => -2 + i * 0.2)];
double[] y = x;
var z = new double[y.Length, x.Length];
for (var r = 0; r < y.Length; r++)
    for (var c = 0; c < x.Length; c++)
        z[r, c] = Math.Exp(-(x[c] * x[c] + y[r] * y[r]));

Plt.Create()
    .With3DRotation()
    .AddSubPlot(1, 1, 1, ax => ax
        .Surface(x, y, z)
        .Text3D(0, 0, 1.0, "Peak", s => { s.Color = Colors.Red; })
        .WithCamera(elevation: 35, azimuth: -55))
    .Save("text3d.svg");

Polar

using MatPlotLibNet;

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");

A polar axes has one radial scale that the rings and every polar series read, and with SetBarMode(BarMode.Stacked) its PolarBar series stand on one another, one call for each speed class; see Examples.


Hierarchical / Flow

Treemap & Sunburst

using MatPlotLibNet;
using MatPlotLibNet.Models;

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");

Icicle

Icicle(root) draws a TreeNode hierarchy as one row of cells for each level, the cells of a node side by side under it, each as wide as its share of the node's width. It takes the colour map, labels and limits the other hierarchical series take; the root itself is not drawn.

using MatPlotLibNet;
using MatPlotLibNet.Models;

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().AddSubPlot(1, 1, 1, ax => ax.Icicle(tree)).Save("icicle.svg");

Two rows of cells: Engineering, Sales and Operations on the top row and under each the cells of its parts

Dendrogram

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.

using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Styling;

// Four leaves; each internal node's Value is the distance at which its children merged.
var tree = new TreeNode
{
    Label = "root", Value = 3.0,
    Children =
    [
        new TreeNode { Label = "ab", Value = 1.0, Children = [new TreeNode { Label = "a" }, new TreeNode { Label = "b" }] },
        new TreeNode { Label = "cd", Value = 2.0, Children = [new TreeNode { Label = "c" }, new TreeNode { Label = "d" }] },
    ],
};

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.

Clustermap

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.

using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Styling.ColorMaps;

double[,] data =
{
    { 0.9, 0.1, 0.8 },
    { 0.2, 0.7, 0.3 },
    { 0.8, 0.2, 0.9 },
};

// Rows 0 and 2 are alike, so they merge first; columns 0 and 2 likewise.
var rowTree = new TreeNode
{
    Value = 1.2,
    Children =
    [
        new TreeNode { Value = 0.3, Children = [new TreeNode { Value = 0 }, new TreeNode { Value = 2 }] },
        new TreeNode { Value = 1 },
    ],
};
var colTree = new TreeNode
{
    Value = 1.1,
    Children =
    [
        new TreeNode { Value = 0.2, Children = [new TreeNode { Value = 0 }, new TreeNode { Value = 2 }] },
        new TreeNode { Value = 1 },
    ],
};

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 = DivergingColorMaps.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.

Pair Grid

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.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

double[] petalLength = [1.4, 1.3, 4.7, 4.5, 6.0, 5.1];
double[] petalWidth  = [0.2, 0.2, 1.4, 1.5, 2.5, 1.9];
double[] sepalLength = [5.1, 4.9, 7.0, 6.4, 6.3, 5.8];
double[] sepalWidth  = [3.5, 3.0, 3.2, 3.2, 3.3, 2.7];
Species[] species    = [Species.Setosa, Species.Setosa, Species.Versicolor, Species.Versicolor, Species.Virginica, Species.Virginica];

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();

enum Species { Setosa, Versicolor, Virginica }

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.

Treemap drilldown (v1.1.4, every node visible on first paint since v1.7.2)

Treemap drilldown

Steady pictures. 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.

using MatPlotLibNet;
using MatPlotLibNet.Models;

var catalogue = new TreeNode
{
    Label = "Catalogue",
    Children =
    [
        new TreeNode { Label = "Books", Children = [new TreeNode { Label = "Fiction", Value = 120 }, new TreeNode { Label = "Science", Value = 80 }] },
        new TreeNode { Label = "Music", Children = [new TreeNode { Label = "Jazz", Value = 60 }, new TreeNode { Label = "Classical", Value = 40 }] },
    ],
};

Plt.Create()
    .WithTreemapDrilldown()
    .AddSubPlot(1, 1, 1, ax => ax.Treemap(catalogue, s => s.ShowLabels = true))
    .Save("treemap_drilldown.svg");

Tree grid

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 one running system, two of its 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.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Styling;

TreeGridRow[] rows =
[
    new("Svc.Ingest", ["56.0%", "1450 MB", "34"]) { Depth = 0, Expanded = true, Url = "/?process=ingest" },
    new("obs-ingest",    ["31.2%",  "—",      "12"]) { Depth = 1 },
    new("metrics-fold",  ["18.7%",  "—",       "9"]) { Depth = 1, Accent = Colors.Orange },
    new("Ldr.Billing",   ["46.0%",  "920 MB", "27"]) { Depth = 0, Expanded = false, Url = "/?process=billing" },
];

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.

Nested pie (v1.1.4)

Nested pie

Renders a two-level TreeNode as an inner pie with an outer breakdown ring. It wraps Sunburst(...) with InnerRadius = 0.

using MatPlotLibNet;
using MatPlotLibNet.Models;

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");

Network Graph

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. Five layouts place the nodes: Manual keeps the coordinates you give, Circular, Hierarchical and Arc are fixed arithmetic (Arc puts the nodes in a row, in the order stored, and draws every edge as a semicircle above it, a half circle under SetAspect(1)), and ForceDirected (Fruchterman–Reingold) pulls connected nodes together, starting from positions drawn with a seed, so the same graph and seed give the same picture.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Styling.ColorMaps;

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.

Relative Rotation Graph (RRG)

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.

using MatPlotLibNet;
using MatPlotLibNet.Styling.ColorMaps;

// Sixty daily closes per asset; each drifts at its own rate against the benchmark.
double[] Closes(double start, double drift) => [.. Enumerable.Range(0, 60).Select(i => start * (1 + drift * i + 0.01 * (i % 7)))];
double[] btcCloses = Closes(60_000, 0.001);
double[] ethCloses = Closes(3_000, 0.003);
double[] bnbCloses = Closes(500, -0.001);
double[] solCloses = Closes(150, 0.002);

// 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)
double[][] assetCloses = [ethCloses, bnbCloses, solCloses];
double[] bench = btcCloses;
string[] labels = ["ETH", "BNB", "SOL"];
double[] absorptionPerBar = [.. Enumerable.Range(0, 60).Select(i => 0.4 + 0.005 * i)];
double[] enbPerBar = [.. Enumerable.Range(0, 60).Select(i => 2.0 + (i % 10) * 0.1)];

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.

Sankey

Process distribution Sankey

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 where Home reappears across columns, pinned with an explicit SankeyNode.Column
  • sankey_un_expenses.svg — a 2-column baseline with outside labels on a HideAllAxes() 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:

using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Styling;

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 blends from the colour of the source to the colour of the target along each link, in an SVG, a PNG and a PDF alike.
SankeySeries.Orient Horizontal (default) / Vertical — top-to-bottom flow
SankeySeries.InsideLabels Draw labels centred inside the node rect (when NodeWidth is wide enough)

Chord

Chord(names, flows) draws the flows between groups as a ring of arcs with a ribbon through the centre for each flow. An arc is as long as the row of its group, and a ribbon takes the colour of the group that sends more of the two. A ChordSeries has no axes around it:

using MatPlotLibNet;

string[] regions = ["North", "South", "East", "West"];
double[,] moves =
{
    { 0, 12, 8, 3 },
    { 9, 0, 5, 7 },
    { 4, 6, 0, 10 },
    { 2, 8, 11, 0 },
};

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Chord(regions, moves)).Save("chord.svg");

A ring of four arcs joined through the centre by ribbons

Property Meaning
Names, Flows The groups, and Flows[i, j], how much goes from group i to group j. The matrix must be square, with no negative or infinite flow and at least one above zero, or the call is refused by name
Padding The gap between neighbouring arcs, in radians; 0.05 unless said otherwise
RibbonAlpha How opaque a ribbon is, from 0 to 1; 0.67 unless said otherwise, so a ribbon behind another shows
Colors The colour of each group, by position; what the theme hands out when null

The ribbons inside an arc run in the order that makes the ribbons of a complete graph cross least (0, 1, 5 and 15 crossings for three to six groups, the fewest any layout can give). The names stand outside the ring and never read upside down. The table is source, target and value for each flow there is.


Vector / Flow

using MatPlotLibNet;

// A rotating field on a 5×5 grid.
double[] x = [.. Enumerable.Range(0, 25).Select(i => (double)(i % 5) - 2)];
double[] y = [.. Enumerable.Range(0, 25).Select(i => (double)(i / 5) - 2)];
double[] u = [.. y.Select(b => -b)];
double[] v = x;

// Barbs take a speed and a compass direction (degrees, 0 = north, clockwise) per point.
double[] speed     = [.. u.Zip(v, (a, b) => 10 * Math.Sqrt(a * a + b * b))];
double[] direction = [.. u.Zip(v, (a, b) => (450 - Math.Atan2(b, a) * 180 / Math.PI) % 360)];

// Streamplot takes the field on a grid: gu[row, column] over gy[row] and gx[column].
double[] gx = [-2, -1, 0, 1, 2];
double[] gy = [-2, -1, 0, 1, 2];
var gu = new double[gy.Length, gx.Length];
var gv = new double[gy.Length, gx.Length];
for (var r = 0; r < gy.Length; r++)
    for (var c = 0; c < gx.Length; c++)
    {
        gu[r, c] = -gy[r];
        gv[r, c] = gx[c];
    }

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Quiver(x, y, u, v)).Save("quiver.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Barbs(x, y, speed, direction)).Save("barbs.svg");   // wind speed + direction
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Streamplot(gx, gy, gu, gv)).Save("stream.svg");   // u, v on a grid

Specialty

Sparkline dashboard

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

string[] categories = ["Speed", "Power", "Range", "Comfort", "Price"];
double[] values     = [4, 3, 5, 2, 4];
string[] labels     = ["Start", "Sales", "Costs", "Tax", "End"];
double[] deltas     = [100, 40, -25, -10, 105];
double[] timeSeries = [3, 4, 3.5, 5, 4.8, 6, 5.5];
string[] headers    = ["Name", "Qty"];
string[][] rows     = [["Apples", "12"], ["Pears", "7"]];
double[] signal     = [.. Enumerable.Range(0, 4096).Select(i => (i % 100) / 100.0)];
BarRange[][] ranges = [[new(1, 2), new(4, 1.5)], [new(0.5, 1), new(3, 2.5)]];   // one row per array: (start, width)

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Radar(categories, values)).Save("radar.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Waterfall(labels, deltas, w => w.TotalIndices = [0, 4])).Save("waterfall.svg");   // Start and End are totals: they stand on zero
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Funnel(labels, values)).Save("funnel.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Gauge(0.72, g => { g.Min = 0; g.Max = 1; })).Save("gauge.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.ProgressBar(0.65)).Save("progress.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Sparkline(timeSeries)).Save("sparkline.svg");
Plt.Create().Table(rows, t => t.ColumnHeaders = headers).Save("table.svg");
Plt.Create().Spectrogram(signal, sampleRate: 44100).Save("spec.svg");
Plt.Create().AddSubPlot(1, 1, 1, ax => ax.BrokenBarH(ranges)).Save("brokenbar.svg");

A spectrogram weights each window once with the Hann window, transforms it at exactly the window's length, and draws each row on the frequency of its bin, so a window of 100 samples at 1000 samples a second gives 51 rows 10 Hz apart and a 125 Hz tone fills the rows of 120 Hz and 130 Hz. See the examples page.

Waffle(categories, values) fills a grid of square cells, ten by ten unless Rows and Columns say otherwise, by category in proportion to its share, so "46 of every 100" is forty-six squares you can count. The cells left over after each category took the whole number its share comes to go to the largest remainders, and the cells fill from the bottom-left corner, row by row.

using MatPlotLibNet;

string[] heating = ["Gas", "Electric", "Heat pump", "Other"];
double[] households = [46, 31, 17, 6];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Waffle(heating, households).WithLegend()).Save("waffle.svg");

A ten by ten grid of square cells filled by category: 46 for gas, 31 for electric, 17 for heat pumps and 6 for other

A waterfall can mark a level on each bar: Targets holds one absolute level for each step, on the scale the bars stand on, and the chart draws a short mark across the bar at each level, over it. A missing level (NaN) is a step with no mark, and the value axis makes room for the highest. A TableSeries takes ColumnAlignments (left, centre or right for each data column) and ColumnWidths (relative widths).

A waterfall of six bars with a short black mark across each bar at its target


Ternary

Three parts that make a whole, in a triangle. TernaryScatter(top, left, right) puts a marker on each composition and TernaryPlot(top, left, right) joins them with a line. A ternary axes is a fourth coordinate system beside cartesian, polar and 3-D (CoordinateSystem.Ternary), with a grid at every fifth and ticks that run counter-clockwise: the right part along the base, the top part up the right edge, the left part down the left edge. The corners are named by the labels the axes already has: SetYLabel at the apex, SetZLabel at the bottom left and SetXLabel at the bottom right.

using MatPlotLibNet;

double[] clay = [0.55, 0.30, 0.12, 0.20, 0.40, 0.08, 0.28, 0.65];
double[] silt = [0.25, 0.40, 0.18, 0.60, 0.15, 0.12, 0.22, 0.15];
double[] sand = [0.20, 0.30, 0.70, 0.20, 0.45, 0.80, 0.50, 0.20];

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .SetYLabel("Clay").SetZLabel("Silt").SetXLabel("Sand")
        .TernaryScatter(clay, silt, sand))
    .Save("soil.svg");

A triangle with Clay at the top, Silt at the bottom left and Sand at the bottom right and eight points inside it

The amounts stay as the author knew them, a count, a percentage or a mass, and are closed to fractions only to be placed, so (2, 5, 3) and (20, 50, 30) stand at the same place and the table still shows what was typed. A sample with a negative, infinite or missing part, or with three zeros, is refused by its row and its part. The triangle is a fixed frame: a wheel, a drag or Home over it leaves the chart as it is, because a composition cannot lie outside it. Hovering a point names its composition. The chart is held against mpltern in the fidelity suite.

Piper diagram

Piper(samples) takes a WaterSample in mg/L for each water (calcium, magnesium, sodium, potassium, bicarbonate, carbonate, sulphate and chloride) and draws the cations on one triangle, the anions on a second and both on a diamond between and above them, one colour for each water on all three panels. The percentages are of milliequivalents:

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

WaterSample[] waters =
[
    new() { Label = "Mountain spring", Ca = 60, Mg = 10, Na = 8, K = 2, HCO3 = 200, SO4 = 20, Cl = 10 },
    new() { Label = "Brine well", Ca = 400, Mg = 100, Na = 5000, K = 50, HCO3 = 150, SO4 = 300, Cl = 8000 },
    new() { Label = "Sodium spring", Ca = 10, Mg = 3, Na = 300, K = 5, HCO3 = 600, SO4 = 30, Cl = 40 },
];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Piper(waters)).Save("piper.svg");

Two triangles and a diamond with a marker for each of three waters

The table gives the six percentages, the charge balance error and "balanced" or "unbalanced" for each water: a water whose charges do not balance, beyond 5 %, is flagged and not dropped. A water with an amount that is negative, infinite or missing, or with no cations or no anions, is refused by its row and its ion. A PiperSeries has no axes around it.


Text

Word cloud

WordCloud(words, weights) sets each word in a size that follows the square root of its weight, between two sizes (12 and 60 unless Sizes says otherwise), the heaviest word at the centre and the rest on a spiral at the first place where they fit with two pixels clear:

using MatPlotLibNet;

string[] words = ["price", "support", "speed", "design", "docs", "bugs", "export", "themes", "charts", "install", "license", "maps"];
double[] weights = [42, 35, 30, 24, 22, 18, 16, 14, 13, 9, 7, 5];

Plt.Create().AddSubPlot(1, 1, 1, ax => ax.WordCloud(words, weights)).Save("cloud.svg");

Twelve words in different colours and sizes with no overlap

Nothing is dropped silently: when the words do not all fit, every size shrinks toward the smallest together, and a word that still has no room is announced on ChartDiagnostics. A word is moved toward the text colour until it reads against the background (4.5 to 1, 3 to 1 for large words), each word carries its weight as a title ("price: 42"), and the SVG asks the browser to set each word to the width it was measured at, so a different font cannot make one run into its neighbour. A weight that is not above zero, an empty word and a repeated word are refused when the call is made. The table has a row for each word, heaviest first, with its weight and size.


Control room

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.

Control room

The picture is the output of the code below, at 1200×630.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Styling;

// The caller supplies the end of the window; the library never reads the clock itself.
var now = DateTime.UtcNow;
double At(int secondsAgo) => now.AddSeconds(-secondsAgo).ToOADate();

double[] clock         = [.. Enumerable.Range(0, 300).Select(i => At(300 - i))];
double[] latency       = [.. Enumerable.Range(0, 300).Select(i => 180 + (i % 40) * 3.0)];
double[] decideHistory = [4.1, 3.9, 4.0, 3.7, 3.8, 3.6, 3.8];
double[] latencyTail   = [240, 251, 262, 270, 288, 301, 312];
StateSegment[] services =
[
    new(At(300), At(200), "up",       Colors.Green),
    new(At(200), At(140), "degraded", Colors.Orange),
    new(At(140), At(90),  "no data",  Colors.Gray),
    new(At(90),  At(0),   "up",       Colors.Green),
];

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   = "Relay (µ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   = "Price 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");

The tile

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

What a tile is in

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:

using MatPlotLibNet;
using MatPlotLibNet.Models;

double p99 = 27.4;   // your measurement

Plt.OpsDashboard()
   .WithWindow(DateTime.UtcNow, TimeSpan.FromMinutes(5))
   .AddTile(p99, t =>
   {
       t.Label = "Request p99";
       t.Condition = p99 > 30 ? new OpsCondition(OpsSeverity.Critical)
                   : p99 > 25 ? new OpsCondition(OpsSeverity.Warning)
                   : OpsCondition.Resting;
   })
   .Build()
   .Save("p99_tile.svg");

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.

Marking a deploy

using MatPlotLibNet;

var now = DateTime.UtcNow;
var deployedAt = now.AddMinutes(-2);
double[] clock   = [.. Enumerable.Range(0, 300).Select(i => now.AddSeconds(i - 300).ToOADate())];
double[] latency = [.. Enumerable.Range(0, 300).Select(i => 180 + (i % 40) * 3.0)];

Plt.OpsDashboard()
   .WithWindow(now, TimeSpan.FromMinutes(5))
   .WithEventMarker(deployedAt, m => m.Label = "deploy 1.4.2")
   .AddTile(latency[^1], t => t.Label = "Bus latency (µs)")
   .AddTrend(clock, latency, s => s.Label = "latency µs")
   .Build()
   .Save("deploy_marker.svg");

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.

Thresholds on a chart

A threshold line marks which side counts as a breach, and the zone on that side is shaded up to the edge of the plot. A threshold beyond the data widens the axis so its line and zone are drawn. The legend shows the live reading beside the series name.

using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Styling;

double[] hours = [0, 1, 2, 3, 4, 5, 6, 7];
double[] load  = [42, 55, 61, 78, 86, 73, 64, 58];

Plt.Create()
   .AddSubPlot(1, 1, 1, ax => ax
       .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()
       .WithLegendValues())   // the reading beside the name; the order of the two calls does not matter
   .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.

The runnable one: MatPlotLibNet.Samples.ControlRoom

dotnet run --project Samples/MatPlotLibNet.Samples.ControlRoom

This 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.


Geographic / Maps — MatPlotLibNet.Geo

This package adds 13 map projections, GeoPolygonSeries (which renders any closed ring as a styled polygon, holes included: a map coloured by a value, ocean fill, land fill or a custom shape), and embedded Natural Earth 110m data (coastlines and countries, with no external download).

using MatPlotLibNet;
using MatPlotLibNet.Geo;
using MatPlotLibNet.Geo.Projections;
using MatPlotLibNet.Styling;

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");

Projections (13)

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.

GeoPolygonSeries — render any closed ring

using MatPlotLibNet;
using MatPlotLibNet.Geo;
using MatPlotLibNet.Geo.Data;
using MatPlotLibNet.Geo.Projections;
using MatPlotLibNet.Geo.Series;
using MatPlotLibNet.Styling;
using MatPlotLibNet.Styling.ColorMaps;

// One series for each country, coloured by hand (Choropleth, below, does this for every country at once).
var countryData = new Dictionary<string, double> { ["France"] = 68, ["Germany"] = 84, ["Spain"] = 48 };
double maxValue = countryData.Values.Max();
var projection = new Robinson();

Plt.Create()
    .AddSubPlot(1, 1, 1, ax =>
    {
        ax.WithProjection(projection);

        foreach (var (country, value) in countryData)
        {
            var poly = NaturalEarth110m.Countries().First(c => c.Properties["NAME"] == country);
            ax.AddSeries(new GeoPolygonSeries([poly], projection)
            {
                Color       = ColorMaps.Viridis.GetColor(value / maxValue),
                StrokeColor = Colors.White,
                StrokeWidth = 0.3,
            });
        }
    })
    .Save("choropleth.svg");

NaturalEarth110m — embedded reference data

The package bundles GeoJSON for the Natural Earth 110m cultural and physical datasets. There is no HTTP fetch, no download and no external dependency.

Member Contents
NaturalEarth110m.Coastlines() World coastline rings (134 features)
NaturalEarth110m.Countries() World countries with Properties["NAME"], ["ISO_A2"], etc. (177 features)

Convenience extensions

using MatPlotLibNet;
using MatPlotLibNet.Geo;
using MatPlotLibNet.Geo.Projections;
using MatPlotLibNet.Styling;

var projection = new EqualEarth();

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .WithProjection(projection)                     // Hides the axes chrome for a map
        .Ocean(projection, new Color(180, 220, 255))    // Fills entire map background
        .Land(projection, new Color(240, 230, 200))     // Fills land masses
        .Coastlines(projection, Colors.Black, 0.5)      // Adds Natural Earth 110m coastlines
        .Borders(projection, Colors.Gray, 0.3))         // Adds Natural Earth 110m country borders
    .Save("equal_earth.svg");

Each 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 custom-shape rendering.

A map axes has one projection: WithProjection records it, and every layer reads it. Land(color), Ocean(color), Borders() and Coastlines() need no projection of their own, and are refused with "call WithProjection first" when the axes has none. A layer that is given a projection has to give the axes' (the same kind with the same numbers), or it is refused with an ArgumentException naming both.

using MatPlotLibNet;
using MatPlotLibNet.Geo;
using MatPlotLibNet.Geo.Projections;

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Coastlines()
        .Borders())
    .Save("world_map.svg");

Choropleth and data layers

using MatPlotLibNet;
using MatPlotLibNet.Geo;
using MatPlotLibNet.Geo.Projections;
using MatPlotLibNet.Styling.ColorMaps;

// Each country takes the colour its value has in the colour map; the values are joined by the three-letter code.
var populationMillions = new Dictionary<string, double> { ["FRA"] = 68, ["DEU"] = 84, ["ESP"] = 48 };

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Choropleth(populationMillions, ColorMaps.Viridis)
        .WithColorBar())
    .Save("choropleth_values.svg");
Verb Draws
Choropleth(values, colorMap), Choropleth("POP_EST", colorMap) each country in the colour its value has in the colour map: values joined by ADM0_A3, or a numeric property of the features (GeoFeature.NumberOf, read invariantly); a country with no value takes the map's bad colour, and WithColorBar() reads the range of the values that can be drawn
Points(latitudes, longitudes) a scatter of places
Bubbles(latitudes, longitudes, values) a bubble at each place, its area in proportion to its value, the largest 400 square points
HexBins(latitudes, longitudes) hexagon bins in the plane of the map
Flows(flows, color, maxWidth) a line along the great circle for each GeoFlow(From, To, Value), as wide as its quantity, cut where it crosses the antimeridian
Cartogram(values) each country resized so that its area is in proportion to its value, about its own centre (a non-contiguous cartogram)
Dorling(values) a bubble at the centre of each country, its area in proportion to its value

A place the projection cannot place, such as the far side of an orthographic globe, is left out. The layers are shown with pictures on the examples page.

A map in JSON

A map writes its projection (its kind and the numbers it was made with, by their PROJ names lon_0, lat_0, lat_1, lat_2), its features (the id of the built-in Natural Earth set, or a GeoJSON FeatureCollection with the holes kept, written by GeoJsonWriter), and its values, colour map and normaliser, into the package data of the series, and a figure that holds one comes back from JSON as the picture it was. The package registers the reader when it loads; a process that reads the JSON before it has used any type of MatPlotLibNet.Geo calls GeoPolygonSeries.RegisterReader() once. The MCP server does not list maps.

Clone this wiki locally