Skip to content

Advanced

H.P. Gansevoort edited this page Sep 12, 2026 · 14 revisions

Advanced


Date Axes

Date axis with auto ticks

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

Math Text Labels

Math text labels with Greek letters and super/subscript

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 ℝ, ℂ, ℤ, ℕ, ℚ, →, ⇒, ←, ↔, ∀, ∃, ∈, ∉, ∪, ∩.

MathText — fractions, sqrt, accents, font variants


Constrained Layout

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, …).

Outside legends

Outside legend — right margin reserved

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.

Label collision avoidance

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 leaderThreshold pixels from its original anchor, it records a leader-line anchor so the caller can draw a connector back with CalloutBoxRenderer.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);
}

Layouts

GridSpec layout

GridSpec — Unequal Subplot Sizes

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

Shared Axes

.AddSubPlot(2, 1, 1, ax => ax.ShareX("group1").Plot(x, y1))
.AddSubPlot(2, 1, 2, ax => ax.ShareX("group1").Plot(x, y2))

Inset Axes

ax.Plot(x, y)
  .AddInset(0.6, 0.6, 0.35, 0.35, inset => inset
      .Plot(xZoom, yZoom).WithTitle("Detail"));

Custom Spacing

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

Animations (Browser / SignalR)

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

GIF Export

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 — Camera, Lighting, and Rotation

3-D charts render as SVG using a perspective camera with depth-sorted polygons.

Cross-series depth composition

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

Matplotlib face shading

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.

SVG vs PNG rendering parity

A 3-D axes rendered to SVG is byte-identical in layout to the same axes rendered to PNG:

  • Text measurement is unified: SvgRenderContext and SkiaRenderContext both delegate to ChartServices.FontMetrics, which delegates in turn to SkiaFontMetrics (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's svg.fonttype='path', so the text renders identically whatever fonts are installed.
  • Figure-level spacing (TightLayout / ConstrainedLayout) runs once via a shared ChartRenderer.PrepareSpacing helper used by both the SVG and PNG transform paths.

Camera

Plt.Create()
    .AddSubPlot(1, 1, 1, ax =>
    {
        ax.Surface3D(x, y, z);
        ax.WithCamera(elevation: 35, azimuth: -55, distance: 8);
    })
    .Save("surface3d.svg");
  • elevation is the camera tilt in degrees (default 30°).
  • azimuth is the camera rotation in degrees (default −60°).
  • distance is the perspective distance and must be at least 2.0. Omit it for an orthographic projection.

Lighting

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.

Interactive SVG Rotation

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.


Sankey hover emphasis

Sankey with hover emphasis

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

Real-Time Charts

ASP.NET Core + SignalR

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

Interactive Popup (no server)

using MatPlotLibNet.Interactive;

var handle = await Plt.Create().Plot(x, y).Build().ShowAsync();
// Recalculate and push update
await handle.UpdateAsync();

Clone this wiki locally