Repository navigation
Advanced

Plot time-series data with automatic tick placement. The DateTime[] overload sets the X axis to AxisScale.Date automatically.
using MatPlotLibNet;
DateTime[] dates = Enumerable.Range(0, 365)
.Select(i => new DateTime(2025, 1, 1).AddDays(i))
.ToArray();
double[] values = dates.Select((_, i) => Math.Sin(i / 30.0)).ToArray();
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Plot(dates, values)
.SetXLabel("Date"))
.Save("dates.svg"); // ticks: "Jan 2025", "Feb 2025", …The same call works for sub-day data:
using MatPlotLibNet;
DateTime[] hours = Enumerable.Range(0, 48)
.Select(i => DateTime.Today.AddHours(i))
.ToArray();
double[] measurements = hours.Select((_, i) => 20 + 5 * Math.Sin(i / 4.0)).ToArray();
Plt.Create().Plot(hours, measurements).Save("hourly.svg");
// ticks: "00:00", "06:00", "12:00", …AutoDateLocator chooses the tick interval from years, months, weeks, days, hours, minutes, and seconds. To set the locator and formatter yourself:
using MatPlotLibNet;
using MatPlotLibNet.Rendering.TickFormatters;
using MatPlotLibNet.Rendering.TickLocators;
DateTime[] dates = Enumerable.Range(0, 30)
.Select(i => new DateTime(2025, 1, 1).AddDays(i))
.ToArray();
double[] values = dates.Select((_, i) => Math.Cos(i / 5.0)).ToArray();
var locator = new AutoDateLocator();
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Plot(dates, values)
.SetXTickLocator(locator)
.SetXTickFormatter(new AutoDateFormatter(locator))) // the formatter reads the interval the locator chose
.Save("dates_custom.svg");
You can use a small subset of LaTeX syntax in any title, axis label, annotation, or legend entry. Wrap the math in $...$.
using MatPlotLibNet;
double[] x = Enumerable.Range(0, 50).Select(i => i * 0.2).ToArray();
double[] y = x.Select(v => 2.0 * v + Math.Sin(v)).ToArray();
// Verbatim strings (@"...") keep the backslashes: in a plain string "\a", "\b" and "\t" are escape characters.
Plt.Create()
.WithTitle(@"$\alpha$ vs $\beta$ correlation")
.AddSubPlot(1, 1, 1, ax =>
{
ax.WithTitle("R$^{2}$ = 0.97");
ax.SetYLabel(@"$\sigma$ (Pa)");
ax.SetXLabel(@"Time $\Delta t$ (ms)");
ax.Plot(x, y);
})
.Save("math.svg");Quick reference:
| Syntax | Renders as |
|---|---|
$\alpha$ |
α |
$\sigma^{2}$ |
σ² |
$x_{i}$ |
xᵢ |
$\pm$ |
± |
$\infty$ |
∞ |
$\leq$ |
≤ |
$\degree$C |
°C |
$\frac{a}{b}$ |
fraction a/b (stacked) |
$\sqrt{x}$ |
√x with overline bar |
$\sqrt[3]{x}$ |
cube root of x |
$\hat{x}$ |
x̂ (circumflex accent) |
$\bar{y}$ |
ȳ (overline accent) |
$\vec{v}$ |
v⃗ (arrow accent) |
$\mathrm{sin}$ |
sin (roman/upright font) |
$\mathbf{F}$ |
F (bold font) |
$\mathcal{L}$ |
ℒ (calligraphic font) |
$\mathbb{R}$ |
ℝ (blackboard bold) |
$\text{ if }$ |
" if " (roman text inside math) |
$a\quad b$ |
a b (em space) |
$\left(...\right)$ |
scaling delimiters |
Text outside $...$ renders as-is. SVG backends emit <tspan baseline-shift="super/sub"> for super/subscripts and stacked <tspan dy="..."> for fractions. Non-SVG backends (Skia, MAUI) fall back to plain-text Unicode substitution.
Math text also supports accents (\hat, \bar, \tilde, \dot, \vec), font variants (\mathrm, \mathbf, \mathit, \mathcal, \mathbb), \text{}, spacing (\,, \:, \;, \quad, \qquad), and 96 symbol mappings that cover Greek letters, math operators, arrows, set and logic symbols, and blackboard bold, for example ℝ, ℂ, ℤ, ℕ, ℚ, →, ⇒, ←, ↔, ∀, ∃, ∈, ∉, ∪, ∩.

Constrained layout computes the figure margins from the measured text extents instead of using hardcoded defaults.
using MatPlotLibNet;
double[] x = [2000, 2005, 2010, 2015, 2020];
double[] y1 = [282, 295, 309, 321, 331];
double[] y2 = [10.3, 13.0, 15.0, 18.2, 21.1];
// TightLayout() and ConstrainedLayout() are equivalent
Plt.Create()
.TightLayout()
.AddSubPlot(2, 2, 1, ax => { ax.SetYLabel("Population (millions)"); ax.Plot(x, y1); })
.AddSubPlot(2, 2, 2, ax => { ax.SetYLabel(@"GDP ($\times 10^{9}$)"); ax.Plot(x, y2); })
.Save("constrained.svg");It measures the Y-tick label widths, the axis label sizes, and the subplot title heights, then computes exact SubPlotSpacing margins and clamps them to sensible ranges. Without either flag, the fixed defaults apply (MarginLeft=60, MarginBottom=50, …).

Four LegendPosition values place the legend box outside the plot area: OutsideRight, OutsideLeft, OutsideTop, OutsideBottom. The constrained-layout engine measures the legend box with LegendMeasurer, which uses the same formulas as the renderer at draw time, and widens the matching figure margin by the box width plus a 16 px gap. The [10, 140] right-margin clamp is dynamic: for OutsideRight it rises to at least legendBoxWidth + 40, so a wide legend never clips.
using MatPlotLibNet;
using MatPlotLibNet.Models;
double[] x = Enumerable.Range(0, 100).Select(i => i * 0.1).ToArray();
double[] sin = x.Select(Math.Sin).ToArray();
double[] cos = x.Select(Math.Cos).ToArray();
double[] decay = x.Select(v => Math.Exp(-v / 5) * Math.Cos(v)).ToArray();
Plt.Create()
.WithSize(900, 500)
.TightLayout()
.AddSubPlot(1, 1, 1, ax =>
{
ax.Plot(x, sin, s => s.Label = "sin(x)");
ax.Plot(x, cos, s => s.Label = "cos(x)");
ax.Plot(x, decay, s => s.Label = "exp(-x/5)·cos(x)");
ax.WithLegend(l => l with { Position = LegendPosition.OutsideRight, Title = "Series" });
})
.Save("legend_outside.svg");Without TightLayout() or ConstrainedLayout() the legend clips at the figure edge, because the margin reservation pass only runs when one of those flags is set.
Dense pies, sunbursts, Sankeys, and bar charts can stack their labels on top of each other. LabelLayoutEngine is an iterative pair-wise repulsion solver that spreads them apart:
- It computes each label's bounding rectangle with
FontMetricsDefault.Current. That is the same provider the renderer uses, so layout and drawing agree byte for byte. - For each overlapping pair, it applies a minimum-translation vector (MTV) shift along the axis with the smaller overlap, weighted by each label's priority.
- It clamps every label rectangle back inside the plot bounds on each pass.
- It stops at convergence or after 20 iterations.
- When a label has moved more than
leaderThresholdpixels from its original anchor, it records a leader-line anchor (LeaderLineStart) so the caller can draw a connector from that anchor to the label's final position.
PieSeriesRenderer, SunburstSeriesRenderer, SankeySeriesRenderer, and BarSeriesRenderer use the engine automatically. TreemapSeriesRenderer uses a per-cell measured-fit check instead: each label is constrained to its own cell rectangle, so labels in different cells cannot collide.
You can also call the engine directly:
using MatPlotLibNet;
using MatPlotLibNet.Rendering;
using MatPlotLibNet.Rendering.Layout;
using MatPlotLibNet.Rendering.Svg;
using MatPlotLibNet.Styling;
var font = new Font { Size = 12 };
// Three labels that all want to sit at nearly the same spot.
LabelCandidate[] candidates =
[
new(new Point(200, 150), "North", font),
new(new Point(204, 152), "North-east", font),
new(new Point(198, 155), "East", font, Priority: LabelPriority.High),
];
var plotBounds = new Rect(0, 0, 400, 300);
var placements = LabelLayoutEngine.Place(
candidates,
plotBounds,
FontMetricsDefault.Current,
leaderThreshold: 6.0);
var ctx = new SvgRenderContext();
foreach (var p in placements)
{
if (p.LeaderLineStart is { } anchor)
ctx.DrawLine(anchor, p.FinalPoint, new StrokeStyle(Colors.Black, 0.5, LineStyle.Solid));
ctx.DrawText(p.Text, p.FinalPoint, p.Font, p.Alignment);
}
Console.WriteLine(ctx.GetOutput());
using MatPlotLibNet;
using MatPlotLibNet.Models;
double[] x = Enumerable.Range(0, 40).Select(i => i * 0.25).ToArray();
double[] y = x.Select(Math.Sin).ToArray();
string[] cats = ["Q1", "Q2", "Q3", "Q4"];
double[] vals = [12, 18, 9, 15];
Plt.Create()
.WithGridSpec(2, 2, heightRatios: [2.0, 1.0], widthRatios: [3.0, 1.0])
.AddSubPlot(GridPosition.Single(0, 0), ax => ax.Plot(x, y).WithTitle("Main"))
.AddSubPlot(GridPosition.Single(0, 1), ax => ax.Scatter(x, y))
.AddSubPlot(new GridPosition(RowStart: 1, RowEnd: 2, ColStart: 0, ColEnd: 2), ax => ax.Bar(cats, vals)) // the whole bottom row
.Save("gridspec.svg");using MatPlotLibNet;
double[] x = Enumerable.Range(0, 40).Select(i => i * 0.25).ToArray();
double[] y1 = x.Select(Math.Sin).ToArray();
double[] y2 = x.Select(Math.Cos).ToArray();
Plt.Create()
.AddSubPlot(2, 1, 1, ax => ax.ShareX("group1").Plot(x, y1))
.AddSubPlot(2, 1, 2, ax => ax.ShareX("group1").Plot(x, y2))
.Save("shared_x.svg");using MatPlotLibNet;
double[] x = Enumerable.Range(0, 200).Select(i => i * 0.05).ToArray();
double[] y = x.Select(v => Math.Sin(3 * v) * Math.Exp(-v / 4)).ToArray();
double[] xZoom = x.Where(v => v <= 1.0).ToArray();
double[] yZoom = y.Take(xZoom.Length).ToArray();
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Plot(x, y)
.AddInset(0.6, 0.6, 0.35, 0.35, inset => inset
.Plot(xZoom, yZoom).WithTitle("Detail")))
.Save("inset.svg");An inset is built where it stands: the callback gets a builder over the inset itself, so its legend, tick locators and
formatters (an ITickFormatter of your own among them), scales, overlays and colour bar are the ones you set, and a chart
drawn in an inset by one of the one-call verbs keeps the names it writes on its axes. Its grid is the theme's grid of the
figure it is drawn under, and an inset can hold an inset. The y of an inset is its top edge counted down from the top of its
parent (matplotlib's inset_axes counts up from the bottom).
IndicateZoom() on the builder of an inset has the parent draw a rectangle over the region the inset shows, and the two lines from
it to the inset's frame that matplotlib's rule keeps, so a reader sees where the detail was taken from. The rectangle spans the
limits the inset draws, those you set on it or the ones it scales to its data:
using MatPlotLibNet;
double[] x = [.. Enumerable.Range(0, 200).Select(i => i / 20.0)];
double[] y = [.. x.Select(v => Math.Sin(v) + 0.3 * Math.Sin(5 * v))];
Plt.Create()
.WithTitle("A closer look at the wiggle")
.AddSubPlot(1, 1, 1, ax => ax
.Plot(x, y)
.SetYLim(-1.5, 3.5)
.AddInset(0.5, 0.03, 0.45, 0.35, inset => inset
.Plot(x, y)
.SetXLim(6, 7.5)
.SetYLim(-0.7, 1)
.IndicateZoom()))
.Save("inset_zoom.svg");
The callback sets the colour, the opacity (half by default), the width and the dash pattern of the rectangle and the lines, and
Connectors = false draws the rectangle alone. An inset that lies wholly within its region, or a region wholly within its inset,
draws no lines. Only a Cartesian parent draws the indicator, and it is drawn over the inset, where matplotlib draws it just under.
using MatPlotLibNet;
double[] x = [1, 2, 3, 4];
double[] y = [3, 1, 4, 1];
Plt.Create()
.WithSubPlotSpacing(s => s with { MarginLeft = 80, HorizontalGap = 20 })
.AddSubPlot(1, 2, 1, ax => ax.Plot(x, y))
.AddSubPlot(1, 2, 2, ax => ax.Bar(["A"], [10]))
.Save("spacing.svg");A text, a legend and an image can belong to the figure itself instead of a subplot, as matplotlib's figtext, figlegend and
figimage do. FigText(text, x, y) places a text at a fraction of the figure's width and height counted from its bottom-left corner,
FigLegend() lists the labelled series of every subplot once in a legend of the figure (each subplot's labelled patches after
its series), and FigImage(data, xOffset, yOffset) places
an image of which each value is one pixel of the figure, offset in pixels from the bottom-left corner:
using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Rendering;
using MatPlotLibNet.Styling;
double[] month = [1, 2, 3, 4, 5, 6];
double[] north = [12, 15, 14, 18, 21, 24];
double[] south = [9, 11, 15, 14, 17, 19];
var strip = new double[10, 140];
for (int row = 0; row < 10; row++)
for (int column = 0; column < 140; column++)
strip[row, column] = column;
Plt.Create()
.WithTitle("Orders by region")
.WithSize(640, 440)
.AddSubPlot(1, 1, 1, ax => ax
.WithLegend(visible: false)
.Plot(month, north, s => s.Label = "North")
.Plot(month, south, s => s.Label = "South")
.SetXLabel("Month"))
.FigLegend(legend => legend with { Position = LegendPosition.UpperRight })
.FigText("Source: field survey", 0.01, 0.01)
.FigText("Preliminary", 0.01, 0.97, text =>
{
text.VerticalAlignment = TextVerticalAlignment.Top;
text.Color = Colors.Red;
})
.FigImage(strip, 480, 8)
.Save("figure_overlays.svg");The three are drawn through one pass that both the SVG and the raster renderer run, so every output shows them alike, and a figure with
no subplot at all still draws them. The images stand under every subplot, then the subplots, then the figure's colour bar, texts and
legend. A position outside 0 to 1, or one that is not a number, draws nothing, and an image that reaches past the figure is cut at its
edge. The legend of a figure is static, reserves no room and ignores TightLayout, so switch off the legends of the subplots and stand
it in a corner the charts leave free; a figure has one legend, and a second FigLegend replaces the first. Every example of the three
is on the examples page, with its picture.
using MatPlotLibNet;
using MatPlotLibNet.Animation;
using MatPlotLibNet.Interactive;
double[] x = Enumerable.Range(0, 100).Select(i => i * 0.1).ToArray();
var animation = new AnimationBuilder(frameCount: 60, frame =>
Plt.Create()
.WithTitle($"t = {frame * 0.1:F1}")
.Plot(x, x.Select(v => Math.Sin(v + frame * 0.1)).ToArray())
.Build())
{
Interval = TimeSpan.FromMilliseconds(50),
Loop = true
};
var handle = await Plt.Create().Plot(x, x.Select(Math.Sin).ToArray()).Build().ShowAsync();
// A looping animation plays until it is cancelled: stop it after 10 seconds.
// Without Loop, AnimateAsync returns after the last frame.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await handle.AnimateAsync(animation, cts.Token);Export frames directly to an animated GIF (requires MatPlotLibNet.Skia).
using MatPlotLibNet;
using MatPlotLibNet.Animation;
using MatPlotLibNet.Skia;
double[] x = Enumerable.Range(0, 100).Select(i => i * 0.1).ToArray();
var animation = new AnimationBuilder(60, frame =>
Plt.Create()
.WithTitle($"t = {frame * 0.1:F1} s")
.Plot(x, x.Select(v => Math.Sin(v + frame * 0.2)).ToArray())
.Build())
{
Interval = TimeSpan.FromMilliseconds(80),
Loop = true
};
animation.SaveGif("wave.gif"); // save to file
byte[] gif = animation.ToGif(); // or get bytes (e.g. for Blazor download)Each frame is drawn at its figure's dpi, so WithDpi(192) on the frames doubles the GIF's width and height. The output is a GIF89a file with a NETSCAPE2.0 loop extension. Colors are quantized to a uniform 252-color palette (a 6×7×6 RGB cube), and each frame's data is LZW-compressed. There is no FFmpeg dependency.
3-D charts render as SVG using a perspective camera with depth-sorted polygons.
Every 3-D axes runs one shared depth queue (DepthQueue3D) for all 3-D series on that axes. Each series (Bar3D, PlanarBar3D, Surface, and the others) pushes its drawable primitives into the queue as closures, each with a centroid depth. After all series have rendered, the axes renderer sorts the queue back to front once and invokes the closures in that order. Matplotlib's ax.bar3d sorts each call's faces independently, so there the draw order can visibly break the compositing. Here the insertion order does not affect the output.
using MatPlotLibNet;
using MatPlotLibNet.Styling;
double[] xs = [1, 2, 3, 4, 5];
double[] ysRow0 = [0, 0, 0, 0, 0];
double[] ysRow1 = [1, 1, 1, 1, 1];
double[] ysRow4 = [4, 4, 4, 4, 4];
double[] zs0 = [3, 5, 2, 6, 4];
double[] zs1 = [2, 4, 6, 3, 5];
double[] zs4 = [5, 2, 4, 6, 3];
// The output is the SAME whatever order these calls are made in —
// the shared depth queue handles cross-series sort.
Plt.Create()
.AddSubPlot(1, 1, 1, ax =>
{
ax.PlanarBar3D(xs, ysRow0, zs0, s => s.Color = Colors.Red); // front
ax.PlanarBar3D(xs, ysRow1, zs1, s => s.Color = Colors.Green);
ax.PlanarBar3D(xs, ysRow4, zs4, s => s.Color = Colors.Gold); // back
})
.Save("planar_bars.svg");Surfaces and 3-D bars lit with WithLighting(dx, dy, dz, ambient, diffuse) shade each face by k = ambient + diffuse · (dot(n̂, l̂) + 1) / 2, held to [0, 1], and multiply the face colour by k. At the defaults, 0.3 and 0.7, that is matplotlib's _shade_colors formula, k = 0.65 + 0.35 · dot(n̂, l̂), which maps the signed dot product from [−1, 1] to [0.3, 1.0]. This is not Lambertian shading, which clamps the dot product with max(0, dot): a face turned away from the light darkens to ambient times its base brightness instead of going black, and its hue is preserved. A light of your own (ILightSource) shades a 3-D bar as it shades a surface.
A 3-D axes rendered to SVG is byte-identical in layout to the same axes rendered to PNG:
-
Text measurement is unified:
SvgRenderContextandSkiaRenderContextboth delegate toFontMetricsDefault.Current, which delegates in turn toSkiaFontMetrics(bundled DejaVu Sans glyph widths), so there is no font-fallback drift. -
Glyph rendering in SVG emits text as
<path>elements with real glyph outlines from Skia, which matches matplotlib'ssvg.fonttype='path', so the text renders identically whatever fonts are installed. -
Figure-level spacing (
TightLayout/ConstrainedLayout) runs once via a sharedChartRenderer.PrepareSpacinghelper used by both the SVG and PNG transform paths.
using MatPlotLibNet;
double[] x = Enumerable.Range(0, 25).Select(i => -3 + i * 0.25).ToArray();
double[] y = x;
var z = new double[y.Length, x.Length];
for (int r = 0; r < y.Length; r++)
for (int c = 0; c < x.Length; c++)
z[r, c] = Math.Exp(-(x[c] * x[c] + y[r] * y[r]) / 4);
Plt.Create()
.AddSubPlot(1, 1, 1, ax =>
{
ax.Surface(x, y, z);
ax.WithCamera(elevation: 35, azimuth: -55, distance: 8);
})
.Save("surface3d.svg");-
elevationis the camera tilt in degrees (default 30°). -
azimuthis the camera rotation in degrees (default −60°). -
distanceis the perspective distance and must be at least 2.0. Omit it for an orthographic projection.
// x, y and z as in the camera example above
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax
.Surface(x, y, z)
.WithLighting(dx: 1.0, dy: 0.5, dz: -1.0, ambient: 0.4, diffuse: 0.6)) // light direction, then the two terms
.Save("surface_lit.svg");A face normal is computed per quad, and the face is shaded with the matplotlib formula above: the shaded color replaces the series fill color on each face. The ambient and diffuse terms are kept with the axes and survive a JSON round trip, but the shading of surfaces and bars reads only the light's direction, so today they do not change the picture.
// x, y and z as in the camera example above
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.Surface(x, y, z))
.With3DRotation()
.Save("interactive.svg");With3DRotation() embeds an inline JavaScript block (Svg3DRotationScript). Dragging with the mouse recomputes the elevation and azimuth and re-sorts the polygons by depth. On the keyboard, the arrow keys rotate the view by ±5° and Home resets it to the initial view. There is no framework dependency: the chart works in any browser or HTML email viewer that allows SVG scripts.

FigureBuilder.WithSankeyHover() embeds a script that dims every link not reachable upstream or downstream from the hovered node. This matches ECharts' focus: adjacency. The emphasis is keyboard-accessible through tabindex="0" and focus/blur events.
Every Sankey node rect carries a data-sankey-node-id attribute, and every link path carries data-sankey-link-source and data-sankey-link-target. The script uses those attributes to run a breadth-first search (BFS) in both directions from the hovered node and dims everything outside the reachable set.
using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Models.Series;
SankeyNode[] nodes = [new("Coal"), new("Gas"), new("Power"), new("Homes"), new("Industry")];
SankeyLink[] links =
[
new(SourceIndex: 0, TargetIndex: 2, Value: 40),
new(SourceIndex: 1, TargetIndex: 2, Value: 30),
new(SourceIndex: 2, TargetIndex: 3, Value: 45),
new(SourceIndex: 2, TargetIndex: 4, Value: 25),
];
Plt.Create()
.WithSankeyHover() // embeds the script
.AddSubPlot(1, 1, 1, ax => ax
.HideAllAxes() // optional: a Sankey draws no axes of its own
.Sankey(nodes, links, s =>
{
s.Iterations = 20; // minimise link crossings
s.LinkColorMode = SankeyLinkColorMode.Gradient;
}))
.Save("sankey.svg");Server:
using MatPlotLibNet;
using MatPlotLibNet.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMatPlotLibNetSignalR();
var app = builder.Build();
app.MapChartHub(); // the hub the clients below connect to: /charts-hub
// Push update anywhere in your app
var publisher = app.Services.GetRequiredService<IChartPublisher>();
var figure = Plt.Create().Plot([1.0, 2.0, 3.0], [21.5, 22.1, 21.8]).Build();
await publisher.PublishSvgAsync("sensor-1", figure);
app.Run();Blazor client:
<MplLiveChart ChartId="sensor-1" HubUrl="/charts-hub" />React client:
<MplLiveChart chartId="sensor-1" hubUrl="/charts-hub" />using MatPlotLibNet;
using MatPlotLibNet.Interactive;
double[] x = [1, 2, 3, 4, 5];
double[] y = [2, 4, 3, 5, 4];
var handle = await Plt.Create().Plot(x, y).Build().ShowAsync();
// Recalculate and push update
handle.Figure.Title = "Recalculated";
await handle.UpdateAsync();MatPlotLibNet 1.29.0
- Home
- Getting Started
- Fluent Cheatsheet
- Examples — each release in code
-
Package Map
- MatPlotLibNet — the core package
- Chart Types
- Styling
- Advanced
- Streaming
- Bidirectional SignalR
- Interactive Controls
- MCP Server
- Verso
- Notebooks
- Accessibility
- International Text
- Benchmarks
- Roadmap
- Contributing