Skip to content

Chart Types

H.P. Gansevoort edited this page Sep 13, 2026 · 27 revisions

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.


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)

Basic

Bar and line charts

Line — Plot()

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

Scatter — Scatter()

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

Bar — Bar()

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

Histogram — Hist()

double[] data = /* ... */;
Plt.Create().Hist(data, bins: 20).Save("hist.svg");

Pie / Donut

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

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

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

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.

// 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

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

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)

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

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

OHLC

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

Grid / Field

Heatmap with colormap

Heatmap

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

Contour & Filled Contour

Plt.Create().Contour(x, y, z).Save("contour.svg");
Plt.Create().Contourf(x, y, z).Save("contourf.svg");

Others

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

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.

// 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

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 (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:

Bar3D grouped — 5 rows

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

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.

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

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

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.

Camera, lighting, and rotation

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

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

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

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

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.

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

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.

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

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.

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

Text3D — 3-D annotations (v1.3.0)

Text3D

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

Polar

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

Hierarchical / Flow

Treemap & Sunburst

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

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.

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.

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.

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.

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.

Treemap drilldown (v1.1.4, rewritten in v1.7.2 Phase P, default flipped in v1.7.2 Phase W)

Treemap drilldown

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

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

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.

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

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.

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

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:

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)

Vector / Flow

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

Specialty

Sparkline dashboard

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

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.

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

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:

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

Marking a deploy

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

Thresholds on a chart

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.

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

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

// 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,
    });
}

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.

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

Convenience extensions

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

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

Clone this wiki locally