-
Notifications
You must be signed in to change notification settings - Fork 3
Advanced

Plot time-series data with automatic tick placement. The DateTime[] overload sets the X axis to AxisScale.Date automatically.
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()
.Plot(dates, values)
.WithXLabel("Date")
.Save("dates.svg"); // ticks: "Jan 2025", "Feb 2025", …The same call works for sub-day data:
DateTime[] hours = Enumerable.Range(0, 48)
.Select(i => DateTime.Today.AddHours(i))
.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:
ax.SetXTickLocator(new AutoDateLocator())
.SetXTickFormatter(new AutoDateFormatter());
You can use a small subset of LaTeX syntax in any title, axis label, annotation, or legend entry. Wrap the math in $...$.
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.
// 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.
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
ChartServices.FontMetrics. 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 so the caller can draw a connector back withCalloutBoxRenderer.DrawLeaderLine(ctx, anchor, finalPoint, color).
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:
var candidates = myAnchors.Select(a =>
new LabelCandidate(a.Point, a.Text, font)).ToList();
var placements = LabelLayoutEngine.Place(
candidates,
plotBounds,
ChartServices.FontMetrics,
leaderThreshold: 6.0);
foreach (var p in placements)
{
if (p.LeaderLineStart is { } anchor)
CalloutBoxRenderer.DrawLeaderLine(ctx, anchor, p.FinalPoint, Colors.Black);
ctx.DrawText(p.Text, p.FinalPoint, p.Font, p.Alignment);
}
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(GridPosition.Span(1, 2, 0, 2), ax => ax.Bar(cats, vals))
.Save("gridspec.svg");.AddSubPlot(2, 1, 1, ax => ax.ShareX("group1").Plot(x, y1))
.AddSubPlot(2, 1, 2, ax => ax.ShareX("group1").Plot(x, y2))ax.Plot(x, y)
.AddInset(0.6, 0.6, 0.35, 0.35, inset => inset
.Plot(xZoom, yZoom).WithTitle("Detail"));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");using MatPlotLibNet.Animation;
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, y).Build().ShowAsync();
await handle.AnimateAsync(animation);
// Stop after 10 seconds
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.Animation;
using MatPlotLibNet.Skia;
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)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, Surface3D, 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.
// These two samples produce the SAME output regardless of loop order —
// 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");3-D series with WithLighting(...) use matplotlib's _shade_colors formula: k = 0.65 + 0.35 · dot(n̂, l̂), mapping the signed dot product from [−1, 1] to [0.3, 1.0]. There is no Lambertian max(0, dot) clamp: a back-facing face darkens to 0.3× its base brightness instead of going to zero, and its hue is preserved.
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 toChartServices.FontMetrics, 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.
Plt.Create()
.AddSubPlot(1, 1, 1, ax =>
{
ax.Surface3D(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.
ax.WithLighting(ambient: 0.4, diffuse: 0.6, dirX: 1.0, dirY: 0.5, dirZ: -1.0);This is Lambertian shading with an ambient and a diffuse term. Face normals are computed per quad, and the shaded color replaces the series fill color on each face.
Plt.Create()
.AddSubPlot(1, 1, 1, ax => ax.Surface3D(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.
Plt.Create()
.WithSankeyHover() // embeds the script
.AddSubPlot(1, 1, 1, ax => ax
.HideAllAxes() // bare canvas — no cartesian axes
.Sankey(nodes, links, s =>
{
s.Iterations = 20; // minimise link crossings
s.LinkColorMode = SankeyLinkColorMode.Gradient;
}))
.Save("sankey.svg");Server:
builder.Services.AddMatPlotLibNetSignalR();
// Push update anywhere in your app
await publisher.PublishSvgAsync("sensor-1", figure);Blazor client:
<MplLiveChart ChartId="sensor-1" HubUrl="/charts-hub" />React client:
<MplLiveChart chartId="sensor-1" hubUrl="/charts-hub" />using MatPlotLibNet.Interactive;
var handle = await Plt.Create().Plot(x, y).Build().ShowAsync();
// Recalculate and push update
await handle.UpdateAsync();MatPlotLibNet 1.18.0