Skip to content

Examples

H.P. Gansevoort edited this page Oct 30, 2026 · 12 revisions

Examples

What each release brought, shown in code: a section to each release, the newest first. Every block on this page is written against the library as it is now and compiled by a test together with every other block of this wiki, so what you copy from here builds. Each example says what you should see when you run it. The page starts at 1.19.0; the changelog says what every release changed and why, including the fixes that ask nothing new of your code.

Release What it brought
1.27.0 A word cloud, a chord diagram, a ternary plot and a Piper diagram; linked selection, so a brush on one chart lights the same rows on the others; an animation as one SVG with a slider and a play button; one handler for everything a viewer does, cursors for several viewers of one chart, and a live chart that sends its own gestures; Home that returns to the first view and a MAUI chart that a finger works; streaming forms for 57 of the 59 indicators, and transfer entropy over a rolling window with the Kraskov estimate; the Windows heads built and tested by CI.
1.26.0 A waffle chart, a calendar of events, Point and Figure, Renko and Kagi charts, fills that fade, arrows along a connected scatter, range frames, FillBetweenX and MatShow with an aspect, an icicle chart, an arc diagram, a stem-and-leaf plot and a target on each waterfall bar; parallel coordinates with a scale on each column, a spine's ticks that stand on the spine, a Sankey diagram that blends in a PNG and a PDF, ids of their own for every subplot and SvgIdPrefix for several figures on one page, and the comparison with matplotlib run in CI.
1.25.0 A Q-Q plot against an exponential, uniform, log-normal or Student t distribution, or your own (ProbabilityPlot); the number at risk and the log-rank test under a survival curve (RiskTable); the other control charts and Nelson's eight tests (ShewhartChart); an anchored VWAP and a volume profile; a colour bar whose ticks stand where the normaliser puts the colours, a theme-owned neutral grey, a grid change that follows the theme, an inset built where it stands, a light with a strength of its own, and a relative rotation graph that shows its centre; Save to .png or .pdf without the Skia package refused instead of writing SVG.
1.24.0 Any number of Y axes on either side from two verbs, WithAddRightYAxis and WithAddLeftYAxis, each axis with its own range, ticks, colour, series and legend entry; a Y axis title the same 4 px from its tick labels on both sides, changed with WithYAxisTitleGap; the older second-axis and outward-axis verbs marked obsolete; a legend that stands where it covers the fewest marks, annotations in the theme's text size that can be moved by pixels or out of the way of the data, bar value labels beyond the bar's end, and turned tick labels given room beside the plot; bars stacked on both sides of zero and polar bars that stack on one radial scale, colour bar ticks at round numbers, a filled contour that follows the limits, the layers of a stack plot named in the legend, series refused when they are built and not when they are drawn, and maps coloured by a value, with points, bubbles, hexagon bins, flows, a cartogram and a Dorling map, that come back from JSON.
1.23.0 A third and fourth Y axis, stacked outward two to a side, each keeping any series kind through JSON and its own share of zoom/pan/Home; technical indicators take one OhlcvBars value instead of separate high/low/close/open/volume arrays.
1.22.0 Series that share one colour instead of each taking the next one: a Kaplan-Meier curve's confidence band and censor marks, and two new verbs, Raincloud (a half violin, a box and a group's raw points in one colour) and Horizon (a signed series folded into bands of two hues).
1.21.0 Every chart type and every axes setting back from JSON as itself: a treemap's, sunburst's, dendrogram's and clustermap's trees, a Sankey's nodes and links, a polar chart's data, a tree grid's rows, a streaming chart's points, a planar 3-D bar, and an axes' Z axis, 3-D pane colours and tooltip switch. The MCP server lists and accepts all eight chart types it had refused by name.
1.20.0 Fifteen charts in one call each (lollipop, dumbbell, Pareto, slope graph, dot plot, parallel coordinates, Marimekko, ridgeline, Q-Q plot, Kaplan-Meier, bump chart, control chart, stream graph, calendar heatmap, spy plot), a figure that comes back from JSON as the picture it was, NaN as the one missing value, a PNG drawn at the figure's dpi, and horizontal bars, log-axis bars and waterfall totals that draw what they say.
1.19.0 A line named where it ends instead of in the legend, an axis coloured to match its line in one call, and secondary axes that take their tick settings, keep their colours through JSON, answer a legend click and report their own values.

1.27.0

Three charts the library could not draw join it: a word cloud, a chord diagram and a ternary plot, and the Piper diagram of a water chemist stands on the ternary one. A brush on one chart can light the same rows on the others, and an animation can be one SVG that steps through its frames with no server. The controls on every platform share one interaction host, one handler hears everything a viewer does, and 57 of the 59 indicators follow a live chart bar by bar. All fourteen rows that stood still to come on the roadmap are done in the release.

Added

A word cloud. WordCloud(words, weights) sets each word in a size that follows the square root of its weight, the heaviest in the middle and the rest on a spiral at the first place where they fit. When the words do not all fit, every size shrinks together, and a word that still has no room is announced on ChartDiagnostics:

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()
    .WithTitle("What the survey answers talk about")
    .WithSize(620, 420)
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax.WordCloud(words, weights))
    .Save("survey_words.svg");

Twelve words in different colours and sizes with no overlap, "price" and "support" the largest in the middle and "maps" the smallest

Every word carries its weight as a title, so hovering "price" says "price: 42", and the table beside the chart has a row for each word, heaviest first, with its weight and the size the weight gives it.

A chord diagram. 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:

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()
    .WithTitle("People moving between regions")
    .WithSize(560, 560)
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax.Chord(regions, moves))
    .Save("region_chord.svg");

A ring of four arcs, North in blue, South in orange, East in green and West in red, joined through the centre by ribbons that take the colour of the group that sends more

The ribbons inside an arc run in the order that makes a complete graph cross least, and a matrix that cannot be drawn (not square, a negative flow, all zero) is refused by name.

A ternary plot. TernaryScatter(top, left, right) places three parts that make a whole in a triangle. The amounts can be counts, percentages or masses, because they are closed to fractions only to be placed. The labels the axes already has name the corners: SetYLabel the apex, SetZLabel the bottom left and SetXLabel 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()
    .WithTitle("Soil samples by texture")
    .WithSize(620, 560)
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax
        .SetYLabel("Clay")
        .SetZLabel("Silt")
        .SetXLabel("Sand")
        .TernaryScatter(clay, silt, sand))
    .Save("soil_ternary.svg");

A triangle with Clay at the top, Silt at the bottom left and Sand at the bottom right, a grid at every fifth and eight blue points

The ticks run counter-clockwise: sand along the base, clay up the right edge, silt down the left edge. The triangle is a fixed frame, so a wheel or a drag over it leaves the chart as it is. TernaryPlot draws the same compositions as a line.

A Piper diagram. Piper(samples) takes what a laboratory delivers, a WaterSample in mg/L for each water, and draws its cations on one triangle, its anions on a second and both on a diamond between them. A water has one colour on all three panels:

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()
    .WithTitle("Three waters on a Piper diagram")
    .WithSize(820, 560)
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax.Piper(waters))
    .Save("piper_water.svg");

Two triangles side by side and a diamond above them, each with a blue, an orange and a green marker for the mountain spring, the brine well and the sodium spring

The percentages are of milliequivalents. The table gives them for each water together with the charge balance error and the word "balanced" or "unbalanced", and a water that is out of balance by more than 5 % is flagged, not dropped.

Linked selection. A series that gives its points RowKeys names its rows the way you do, and LinkSelection(group) makes the charts of one page a group. A brush on one chart then lights the same rows on every chart of the group and dims the others, and Escape clears it. The rows given as the second argument start selected:

using MatPlotLibNet;

string[] orders = ["o1", "o2", "o3", "o4", "o5", "o6"];
double[] size = [12, 30, 18, 44, 26, 38];
double[] days = [2, 5, 3, 9, 4, 7];

Plt.Create()
    .WithTitle("The same six orders in two charts")
    .WithSize(860, 380)
    .LinkSelection("orders", ["o2", "o5"])
    .AddSubPlot(1, 2, 1, ax => ax
        .WithTitle("Size and delivery time")
        .SetXLabel("Order size")
        .SetYLabel("Days to deliver")
        .Scatter(size, days, s => s.RowKeys = orders))
    .AddSubPlot(1, 2, 2, ax => ax
        .WithTitle("Size by order")
        .SetXLabel("Order number")
        .SetYLabel("Order size")
        .Scatter([1.0, 2, 3, 4, 5, 6], size, s => s.RowKeys = orders))
    .Save("linked_orders.svg");

Two scatter charts of the same six orders, one of order size against days to deliver and one of order size against order number

Open the SVG in a browser and the points of o2 and o5 stand out in both charts while the other four are dimmed; a Shift+drag over three points of the left chart lights the same three in the right one. The picture is a PNG, which has no script, so it shows all six alike.

An animation as one SVG. SaveStepThroughSvg writes every frame into one file, with a slider, a play button and the arrow keys, and needs no server. The file is as large as all its frames together:

using MatPlotLibNet;
using MatPlotLibNet.Animation;

double[] times = [.. Enumerable.Range(0, 60).Select(i => i * 0.1)];

var waveSteps = new AnimationBuilder(frameCount: 8, frame =>
    Plt.Create()
        .WithTitle($"Wave, phase {frame}")
        .WithSize(560, 320)
        .Plot(times, [.. times.Select(v => Math.Sin(v + frame * 0.4))])
        .Build())
{
    Interval = TimeSpan.FromMilliseconds(400),
    Loop = true,
};

waveSteps.SaveStepThroughSvg("wave_steps.svg");
A sine wave on a plot titled Wave, phase 0; the file steps through eight phases with a slider and a play button when it is opened in a browser

Without its script the file is its first frame, which is what an <img>, a README or a viewer that runs nothing shows, and a reader who asked their system for reduced motion gets no autoplay. Open the file itself to step: Home and End go to the ends, the space bar plays and pauses, and a click on the track goes to that frame. FuncAnimation.Save writes the same file for a .svg path.

One handler for everything a viewer does. A chart offered callbacks for a brush, a hover and a click, and nothing for a zoom, a pan, a legend toggle or a reset. An IChartEventHandler receives every interaction event once, with a context that names the viewer. It is given to ChartSessionOptions.Handle on a server and to the ChartEventHandler of a native control, so one class reacts to a chart wherever it is shown:

using MatPlotLibNet.AspNetCore;
using MatPlotLibNet.Interaction;

// Where a chart is registered: registry.Register("live-1", figure, configure);
Action<ChartSessionOptions> configure = opts => opts.Handle(new ViewerLog());

sealed class ViewerLog : IChartEventHandler
{
    public ValueTask OnEventAsync(FigureInteractionEvent evt, ChartEventContext context)
    {
        Console.WriteLine($"{context.ViewerName ?? "This window"}: {evt.GetType().Name} on {evt.ChartId}");
        return ValueTask.CompletedTask;
    }
}

A handler that throws costs its own event only. A brush also gives the handler a RowSelectionEvent with the keys of the rows it covers, each once, in the order of the data.

Several viewers of one chart, by design. The hub stamps every event with the connection it came in on and the name the chart gave that connection, "Viewer 1", "Viewer 2", over whatever the payload claimed. EnableViewerCursors makes a page send where its pointer is and draw the others' pointers with their names:

using MatPlotLibNet;

Plt.Create()
    .Plot([0.0, 1.0, 2.0, 3.0], [0.0, 1.0, 4.0, 9.0])
    .WithServerInteraction("shared", i => i.EnableBrushSelect().EnableViewerCursors())
    .Build();

After a batch in which a viewer moved the shared view, every viewer is told who. A chart says when it is busy (data-mpl-busy) and when it is idle again (mpl:idle).

A live chart that sends its own gestures. MplLiveChart gives the script inside its SVG the SignalR connection the .NET client already holds, so a zoom, a pan, a legend toggle and Home reach the server with no JavaScript of your own. The React, Vue and Angular components mount a chart so that its script runs, which a script that arrives through innerHTML never does; each package exports mountSvg(host, svg, hub).

A finger works the MAUI chart. One InteractionHost wires pan, zoom, point picking, brush, hover and Home for the WPF, Avalonia, Uno and MAUI controls. On MAUI a drag does what DragGestureAction says (pan, rectangle zoom, brush, span or rotate), a pinch zooms in whole 15 % notches, a tap clicks and a double tap is Home. IsInteractive is off by default, so a chart on a scrolling page does not take the drag meant to scroll it. The streaming controls of WPF, Avalonia and Uno take IsInteractive too: a streaming chart can be panned and zoomed, and its scale mode leaves an axes the reader moved alone until Home hands it back. The MAUI canvas draws hatches, gradients and the arcs of a pie and a donut as the SVG and the PNG do.

Streaming forms for 57 of the 59 indicators. A bar can carry its volume, OhlcvBar, and WithStreaming(axes, indicator) attaches any indicator to a candle series, which feeds it the prices and the volume of each bar. The named verbs stay, and WithStreamingObv and WithStreamingVwap join them:

using MatPlotLibNet;
using MatPlotLibNet.Data;
using MatPlotLibNet.Indicators.Streaming;
using MatPlotLibNet.Models;
using MatPlotLibNet.Models.Series.Streaming;

var figure = new Figure { Title = "Candles with a streaming average and on-balance volume", Width = 760, Height = 520 };
var price = figure.AddSubPlot(2, 1, 1);
var volume = figure.AddSubPlot(2, 1, 2);
var candles = price.AddSeries(new StreamingCandlestickSeries(200));
candles.WithStreamingSma(price, 5);
candles.WithStreamingObv(volume);

double close = 100;
ulong state = 7;
for (int i = 0; i < 60; i++)
{
    state = unchecked(state * 6364136223846793005UL + 1442695040888963407UL);
    double step = ((state >> 11) / (double)(1UL << 53) - 0.5) * 6;
    state = unchecked(state * 6364136223846793005UL + 1442695040888963407UL);
    double traded = 800 + (state >> 11) / (double)(1UL << 53) * 600;
    double open = close;
    close = Math.Max(1, close + step);
    candles.AppendBar(new OhlcvBar(open, Math.Max(open, close) + 1, Math.Min(open, close) - 1, close, traded));
}

figure.Save("streaming_obv.svg");

Sixty candles in green and red with an orange five-bar average over them, and below them a blue line of on-balance volume that rises and falls with the volume of the bars

Every indicator but two has a form: FibonacciRetracement and VolumeProfile have none, and StreamingMap says why. The 46 forms added in 1.27.0 take one of two shapes, a window of the last bars that the batch indicator is run over (24), or the state of the recursion, written once as a step that the batch indicator folds over too (22). Either way, after the bar at index i its value is the last value of the batch indicator over the bars up to i, to 1e-9.

Transfer entropy over time, and without bins. TransferEntropyOptions chooses the estimator, the neighbours, the lag and a series to condition on. With a Window the entropy is one value for each bar, worked out from the last bars, NaN until the first window is full and drawn as a line. A Stride works out every n-th window. The target below follows the source from bar 200 on:

using MatPlotLibNet;
using MatPlotLibNet.Indicators;

const int n = 400;
ulong state = 7;
double Normal()
{
    double sum = 0;
    for (int k = 0; k < 12; k++)
    {
        state = unchecked(state * 6364136223846793005UL + 1442695040888963407UL);
        sum += (state >> 11) / (double)(1UL << 53);
    }

    return sum - 6;
}

double[] source = new double[n];
double[] noise = new double[n];
for (int t = 0; t < n; t++) source[t] = Normal();
for (int t = 0; t < n; t++) noise[t] = Normal();
double[] target = new double[n];
target[0] = noise[0];
for (int t = 0; t + 1 < n; t++) target[t + 1] = (t >= 200 ? 1 : 0) * source[t] + noise[t + 1];

Plt.Create()
    .WithTitle("Does the source lead the target? It does from bar 200")
    .WithSize(760, 380)
    .AddSubPlot(1, 1, 1, ax => ax
        .SetXLabel("Bar")
        .SetYLabel("Transfer entropy (nats)")
        .TransferEntropy(source, target, new TransferEntropyOptions(Neighbours: 4, Window: 100, Stride: 5)))
    .Save("rolling_transfer_entropy.svg");

A line that starts at bar 100 near zero, stays there until bar 200 and climbs to about 0.35 nats by bar 300

The estimate is the Kraskov–Stögbauer–Grassberger nearest-neighbour one, which needs no bins; the overload with bins and lag is the binned estimate it always was. StreamingTransferEntropy takes TransferEntropySamples one at a time and says what the batch says.

The Windows heads are built and tested by CI. The MAUI, WPF and Uno packages were built for the first time by the publish job, after the tag. A windows-heads job of the CI workflow now builds them with the same steps on every push and runs their suites.

Changed

Five streaming indicators give the values of their batch indicators. On-balance volume starts with the volume of the first bar and a bar without a volume gives NaN. The RSI seeds Wilder's averages from the same changes the batch RSI does, the ATR does not count the first bar's range, and the signal of the MACD and the %D of the Stochastic are the simple mean the batch indicators take. An application that pinned the old values pins the new ones. StreamingIndicator.AppendToOutput is obsolete: SetOutput writes every output of an input together.

Home goes back to the first view. The figure remembers the view each axes had before an interaction first moved it, and a reset, Home or a double-click goes back to it, in the browser script and in every native control.

1.26.0

Marks that are a size in data units have a home now: a waffle chart, a calendar of events, Point and Figure and Renko draw squares, stripes and boxes that tile the plot area, and Kagi draws a line that thickens and thins. A fill can run from one colour to another, an arrow can follow a connected scatter, and the frame of a plot can be trimmed: a spine spans only the data's range, a plot can keep an equal scale, and fill_betweenx and matshow have their equivalents. An icicle chart, an arc diagram and a stem-and-leaf plot join the layouts, a waterfall marks a target level on each bar, and the comparison with matplotlib runs in CI. Fifteen rows of the roadmap are done in the release.

Added

A waffle chart. 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 "37 of every 100" is thirty-seven squares you can count. Each category takes the whole number of cells its share comes to and the cells left over go to the largest remainders, so the cells always add up to the grid:

using MatPlotLibNet;
using MatPlotLibNet.Models;

Plt.Create()
    .WithTitle("Households by heating")
    .WithSize(620, 420)
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax
        .Waffle(["Gas", "Electric", "Heat pump", "Other"], [46, 31, 17, 6])
        .WithLegend(LegendPosition.OutsideRight))
    .Save("heating_waffle.svg");

A grid of ten by ten square cells filled from the bottom left: 46 blue for gas, 31 orange for electric, 17 green for heat pumps and 6 red for other, with the legend beside it

The cells fill from the bottom-left corner, row by row, in the order of the categories, and they are square at any figure size. A category with no cell keeps its legend entry and its row in the table.

A calendar of events. CalendarEvents(dates, kinds) lays the days from the earliest event to the latest out as a calendar, every day a cell in the theme's neutral grey, and puts a mark for each event inside the cell of its day. Events on one day stand side by side, and each kind has its own colour and its own legend entry:

using MatPlotLibNet;

DateTime[] days =
[
    new(2025, 3, 3), new(2025, 3, 3), new(2025, 3, 12), new(2025, 3, 19), new(2025, 4, 1),
    new(2025, 4, 1), new(2025, 4, 9), new(2025, 4, 22), new(2025, 5, 6),
];
string[] kinds = ["deploy", "incident", "deploy", "deploy", "deploy", "incident", "incident", "deploy", "deploy"];

Plt.Create()
    .WithTitle("Releases and incidents")
    .WithSize(900, 300)
    .AddSubPlot(1, 1, 1, ax => ax
        .CalendarEvents(days, kinds)
        .WithLegend())
    .Save("calendar_events.svg");

A calendar with the weekdays named on the left and the months along the bottom, a grey cell for every day, and blue and orange marks inside the cells of the days with a deploy or an incident; two marks stand side by side on the days that have two

The two events of the first Monday stand side by side inside its cell, the deploy in blue and the incident in orange. The plot has an aspect of one, so the cells are square.

Point and Figure. PointAndFigure(prices, boxSize, reversal = 3) draws the price as columns of boxes with no axis for time: a column of Xs where it rose, a column of Os where it fell. A column grows while the price reaches the next box in its direction, and gives way to the other kind once the price has come back reversal boxes from the last box drawn. SetAspect(1.0 / boxSize) makes the boxes square:

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()
    .WithTitle("Point and Figure, boxes of 2")
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .PointAndFigure(closes, 2)
        .SetAspect(1.0 / 2))
    .Save("point_and_figure_closes.svg");

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

The first column rises from 100 to 120, the second falls back to 104, and so on to a last column of Xs that reaches 128. The table beside the chart has a row for each column: its number, X or O, and the prices it begins and ends at.

Renko and Kagi. Two verbs of a new family, PriceAxesExtensions, take the closes as one array and draw them without a time axis. Renko(prices, brickSize) is a staircase of bricks of one size: a close on the next brick's trigger draws it, a close that covers several bricks draws each, and a reversal needs two bricks from the far edge. Kagi(prices, reversal) is one path that turns thick where the price breaks above the last shoulder and thin where it breaks below the last waist:

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()
    .WithTitle("Renko, bricks of 3")
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Renko(closes, 3)
        .WithLegend())
    .Save("renko_closes.svg");

Plt.Create()
    .WithTitle("Kagi, a reversal of 4")
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax.Kagi(closes, 4))
    .Save("kagi_closes.svg");

Green bricks that climb and red ones that fall back in a staircase, each brick a column wide and 3 tall, the corner of each on the corner of the one before

One line of vertical stretches and short connectors that thickens where the price breaks above the last shoulder and thins where it breaks below the last waist

The X axis of both carries no ticks, since it is only an order. The callback of each receives its parts (RenkoParts holds the bricks that rose and the bricks that fell, KagiParts the thick and the thin line) to restyle them.

A fill that fades. GradientColor on a bar series or an area series makes the fill run from the series' colour to that colour: along a bar from its foot to its end (the bottom of a bar that points down), 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:

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] x = [0, 1, 2, 3, 4, 5, 6];
double[] y = [2, 5, 4, 8, 6, 9, 7];

Plt.Create()
    .WithTitle("Fills that fade")
    .WithSize(860, 380)
    .AddSubPlot(1, 2, 1, ax => ax
        .Bar(["Q1", "Q2", "Q3", "Q4"], [4, 7, -3, 9], s =>
        {
            s.Color = Colors.Tab10Blue;
            s.GradientColor = Color.FromHex("#C6DBEF");
        }))
    .AddSubPlot(1, 2, 2, ax => ax
        .FillBetween(x, y, null, s =>
        {
            s.Color = Colors.Tab10Orange;
            s.Alpha = 1;
            s.GradientColor = Colors.Tab10Orange.WithAlpha(0);
        }))
    .Save("gradient_fills.svg");

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

The blend is a property of the fill, so a PNG and a PDF draw it as the SVG does (the PDF as a vector shading), and a hatch is laid over it. A Sankey diagram's links blend in a PNG and a PDF now too.

A connected scatter. ConnectedScatter(x, y) is a marker on every point and an arrow from each point to the next, so the path two values take over time can be followed. Each head lands on the next point and points along the segment as the page draws it:

using MatPlotLibNet;

double[] unemployment = [4.0, 4.4, 5.8, 7.7, 9.3, 9.6, 8.9];
double[] inflation = [2.4, 1.6, 2.7, 0.1, 2.7, 1.5, 3.0];

Plt.Create()
    .WithTitle("Unemployment and inflation")
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .ConnectedScatter(unemployment, inflation)
        .SetXLabel("Unemployment (%)")
        .SetYLabel("Inflation (%)"))
    .Save("connected_scatter.svg");

Seven points joined by a line with a small arrow head on each segment showing the way the path runs

A range frame. A spine takes BoundsFrom and BoundsTo, in the data units of its own axis, so an axis line can span only the data. Set both to the data's range and hide the top and right spines for a range frame. The tick marks are not clipped to the bounds, and a spine moved to a data value or a fraction of the axes takes its ticks with it:

using MatPlotLibNet;

double[] x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
double[] y = [3.1, 3.8, 4.2, 4.0, 5.1, 5.9, 5.6, 6.8, 7.2, 7.9];

Plt.Create()
    .WithTitle("A range frame")
    .WithSize(600, 400)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(x, y)
        .WithSpines(spines => spines with
        {
            Bottom = spines.Bottom with { BoundsFrom = 1, BoundsTo = 10 },
            Left = spines.Left with { BoundsFrom = 3.1, BoundsTo = 7.9 },
            Top = spines.Top with { Visible = false },
            Right = spines.Right with { Visible = false },
        }))
    .Save("range_frame.svg");

A line chart whose left line runs only from 3.1 to 7.9 and whose bottom line only from 1 to 10, with no top or right line

Two plots matplotlib has, and an aspect. FillBetweenX(y, x1, x2) is an area that lies down: at each height the fill runs between two curves along X, or from zero to the curve. MatShow(matrix) draws a matrix as square cells, the first row on top, the columns and rows numbered from 0, with a colour bar. Both lean on SetAspect, which says how much longer a unit of Y is than a unit of X on the page; 1 makes a square of data a square, and the plot is the largest rectangle of that shape that fits:

using MatPlotLibNet;

double[] depth = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
double[] speed = [2, 4, 3, 6, 5, 8, 6, 9, 7, 10, 8];

Plt.Create()
    .WithTitle("Speed by depth")
    .WithSize(500, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .FillBetweenX(depth, speed, null, s => s.Alpha = 0.8)
        .SetXLabel("Speed")
        .SetYLabel("Depth"))
    .Save("fill_betweenx_speed.svg");

var correlation = new double[,]
{
    { 1.0, 0.8, 0.1, -0.4 },
    { 0.8, 1.0, -0.3, -0.2 },
    { 0.1, -0.3, 1.0, 0.6 },
    { -0.4, -0.2, 0.6, 1.0 },
};

Plt.Create()
    .WithTitle("Correlation")
    .WithSize(520, 520)
    .AddSubPlot(1, 1, 1, ax => ax.MatShow(correlation))
    .Save("matshow_correlation.svg");

A blue area that runs from the left axis out to a jagged curve, with depth on the vertical axis

A four by four matrix as square cells coloured from purple to yellow, the first row on top, the columns numbered along the top and the bottom, a colour bar on the right

The spy plot and the calendar heatmap set an aspect of one as well now, so their cells are square at any figure size.

Each column of parallel coordinates in its own units. A column's line carries ticks over that column's own range, a short mark with its number to the left at the height that value is drawn at, so a value can be read and not only compared. The chart of 1.20.0 is drawn with them:

Parallel coordinates of three species of iris, each of the four columns with a scale of its own in its own units

An icicle chart. Icicle(root) draws a 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, in the order stored. The root is not drawn:

using MatPlotLibNet;
using MatPlotLibNet.Models;

var budget = new TreeNode
{
    Label = "Budget",
    Children =
    [
        new TreeNode
        {
            Label = "Engineering",
            Children =
            [
                new TreeNode { Label = "Platform", Value = 40 },
                new TreeNode { Label = "Apps", Value = 25 },
                new TreeNode { Label = "Data", Value = 15 },
            ],
        },
        new TreeNode
        {
            Label = "Sales",
            Children = [new TreeNode { Label = "Direct", Value = 30 }, new TreeNode { Label = "Partners", Value = 20 }],
        },
        new TreeNode
        {
            Label = "Operations",
            Children = [new TreeNode { Label = "Support", Value = 18 }, new TreeNode { Label = "Finance", Value = 12 }],
        },
    ],
};

Plt.Create()
    .WithTitle("Where the budget goes")
    .WithSize(760, 360)
    .AddSubPlot(1, 1, 1, ax => ax.Icicle(budget))
    .Save("budget_icicle.svg");

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

An arc diagram. GraphLayout.Arc puts a network graph's nodes in a row, in the order stored, and draws every edge as a semicircle above it. Under SetAspect(1) the arcs are half circles, and the axes make room for the highest:

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

GraphNode[] nodes = [new("Ada"), new("Ben"), new("Cleo"), new("Dev"), new("Eli"), new("Fay"), new("Gus")];
GraphEdge[] edges =
[
    new("Ada", "Dev"), new("Ada", "Gus"), new("Ben", "Cleo"), new("Ben", "Fay"),
    new("Cleo", "Dev"), new("Dev", "Eli"), new("Eli", "Gus"), new("Cleo", "Gus"),
];

Plt.Create()
    .WithTitle("Who works with whom")
    .WithSize(800, 420)
    .AddSubPlot(1, 1, 1, ax => ax
        .NetworkGraph(nodes, edges, s => s.Layout = GraphLayout.Arc)
        .SetAspect(1)
        .HideAllAxes())
    .Save("arc_diagram.svg");

Seven names in a row with eight half-circle arcs above them joining the pairs that work together, the longest from Ada to Gus

A stem-and-leaf plot. StemAndLeaf(sample, leafUnit) writes every observation on a row: the digits above the last one as the stem and the last digit as a leaf, cut toward zero. Each leaf has a column of its own, so the leaves line up in any font. The leaf unit, a power of ten, is chosen from the sample when you give none:

using MatPlotLibNet;

double[] ages = [12, 15, 21, 23, 23, 38, 41, 44, 47, 52, 53, 58, 61];

Plt.Create()
    .WithTitle("Ages")
    .WithSize(500, 350)
    .AddSubPlot(1, 1, 1, ax => ax.StemAndLeaf(ages))
    .Save("stem_and_leaf_ages.svg");

Six rows of stems from 1 to 6, a bar, and the leaves of each in columns under each other, with the key 1 | 2 = 12 at the lower left

The table underneath is a TableSeries, which gained ColumnAlignments (left, centre or right for each data column) and ColumnWidths (relative widths): the stems are aligned to the right, against the bar.

A target on a waterfall. Targets holds one absolute level for each step, on the scale the bars stand on, and the chart marks each level across its bar, over it:

using MatPlotLibNet;

string[] steps = ["Opening", "Q1", "Q2", "Q3", "Q4", "Closing"];
double[] amounts = [100, 30, -20, 40, -10, 140];

Plt.Create()
    .WithTitle("Budget against target")
    .WithSize(640, 400)
    .AddSubPlot(1, 1, 1, ax => ax
        .Waterfall(steps, amounts, s =>
        {
            s.TotalIndices = [0, 5];
            s.Targets = [100, 125, 110, 150, 145, 150];
        })
        .SetYLabel("Budget (k€)"))
    .Save("budget_waterfall.svg");

A waterfall of six bars with a short black mark across each bar at its target: the first quarter ends above its target, the third and the opening exactly on it

A missing target (NaN) is a step with no mark, and the value axis makes room for the highest target.

The comparison with matplotlib runs in CI. The suite that scores the charts against pictures matplotlib drew is in the CI filter and runs in the release job too. Its colour distance reads every pixel now, and the waterfall it holds is the one below, drawn the way its reference is:

A waterfall with a blue opening bar of 100, four steps in green and red and a blue closing bar of 140, on the matplotlib v2 look

Changed

Several figures on one HTML page can share it. SaveOptions.SvgIdPrefix puts a prefix in front of every id an SVG defines and every reference to one, so the figures' clip paths, hatches and gradients cannot collide:

using MatPlotLibNet;

var growth = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.FillBetween([1.0, 2.0, 3.0], [1.0, 4.0, 9.0]))
    .Build();
var decline = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.FillBetween([1.0, 2.0, 3.0], [9.0, 4.0, 1.0]))
    .Build();

growth.Save("growth.svg", new SaveOptions { SvgIdPrefix = "growth" });
decline.Save("decline.svg", new SaveOptions { SvgIdPrefix = "decline" });

Within one SVG, every subplot after the first writes ids with its own number (s1-clip-0, s2-hatch-0), where two subplots had both written clip-0.

1.25.0

A value a chart works out now has one owner that every reader asks. A colour bar puts its ticks where the chart's normaliser puts the colours, a theme owns the grey that reference lines and outlines wear, a grid you changed stays a change over whichever theme the figure is drawn under, an inset is built where it stands, the strength of a light reaches the surfaces and bars it lights, and a relative rotation graph always shows its centre. Fifteen rows of the roadmap are done in the release, and Save("chart.png") without the Skia package no longer writes SVG under a raster name.

The statistics grew with it: a Q-Q plot against other distributions, the number at risk and the log-rank test under a survival curve, the other control charts with Nelson's tests, and an anchored VWAP and a volume profile beside the price.

Added

A Q-Q plot against another distribution. ProbabilityPlot(sample, reference) is QQPlot for a distribution you choose. QQReference names it: Normal, Exponential, Uniform, LogNormal(sigma), FittedLogNormal(sample), StudentT(degreesOfFreedom), or Of(name, quantile) with the quantile function of your own. Waiting times between arrivals are the textbook case for an exponential reference:

using MatPlotLibNet;

double[] waits = [2.1, 0.4, 5.6, 1.3, 0.9, 3.8, 0.2, 1.7, 2.6, 0.6, 8.4, 1.1,
                  0.7, 4.2, 1.9, 0.3, 3.1, 1.5, 0.8, 2.3];

Plt.Create()
    .WithTitle("Minutes between arrivals against the exponential distribution")
    .WithSize(700, 500)
    .AddSubPlot(1, 1, 1, ax => ax
        .ProbabilityPlot(waits, QQReference.Exponential, parts => parts.Points.Alpha = 0.8)
        .SetYLabel("Minutes between arrivals"))
    .Save("probability_plot.svg");

Twenty waiting times against the exponential quantiles: blue points along a red line, the longest wait, 8.4 minutes, above it

The points stand along the red least-squares line, and the longest wait, 8.4 minutes, stands above it. A sample that follows the reference lies on the line up to location and scale: the line climbs about 2.3 minutes for each unit of the exponential's quantile, which is the scale, and starts near 0, which is the location. The X axis is titled "Theoretical quantiles (exponential)"; a reference whose quantile is not a finite number at a plotting position is refused by name. QQPlot is ProbabilityPlot against QQReference.Normal and draws the picture it always did.

The number at risk and the log-rank test. RiskTable(groups) is an axes you stack under KaplanMeier and share the X axis with. For each group and each time it writes how many subjects are still at risk, directly under that time on the curve, and below the rows one line that tests whether the groups' survival differs.

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

SurvivalGroup[] arms =
[
    new("Treatment", [6, 7, 10, 15, 19, 25, 32, 34], [true, true, false, true, false, true, false, false]),
    new("Placebo", [1, 2, 3, 4, 5, 8, 8, 11], [true, true, true, false, true, true, true, true]),
];

Plt.Create()
    .WithTitle("Time to relapse")
    .WithSize(800, 600)
    .WithGridSpec(2, 1, heightRatios: [4, 1])
    .AddSubPlot(GridPosition.Single(0, 0), ax => ax.KaplanMeier(arms).WithLegend(), key: "survival")
    .AddSubPlot(GridPosition.Single(1, 0), ax => ax.ShareX("survival").RiskTable(arms).SetXLabel("Weeks"))
    .Save("risk_table.svg");

A survival curve for two arms with the number at risk under it, and the line "Log-rank χ²(1) = 7.85, p = 0.0051"

The treatment arm has 8 subjects at risk at week 0 and 2 at week 30, the placebo arm 8 at week 0 and 1 at week 10, none from week 15. The line under the rows is the Mantel-Cox log-rank test, the one R's survdiff and lifelines compute: Log-rank χ²(1) = 7.85, p = 0.0051. With no event in any group it says there is nothing to compare, and one group has no test. times chooses the columns; without it they are the ticks the automatic locator gives from 0 to the longest follow-up. The callback receives RiskTableParts (the count annotations, the test line and the LogRankResult). The counts stand exactly under their times because axes that share a scale now share its margins as well as its range (see Changed).

Other control charts, and Nelson's tests. ShewhartChart(data, rules, limits) draws a marked line, the centre line, the two limits and the flagged points for what a ControlChartData plots: Individuals (what ControlChart draws), MovingRange, XBar, Range, P and C. ControlRules is a flags enum numbered as Nelson numbers his eight tests, combined with |. Here the limits are those of an earlier period, and the process drifts up from the eleventh subgroup:

using MatPlotLibNet;
using MatPlotLibNet.Rendering.TickLocators;

// Twenty subgroups of five fills; from the eleventh on the process drifts up by half a gram.
double[][] subgroups = [.. Enumerable.Range(0, 20).Select(i =>
    Enumerable.Range(0, 5).Select(j => 50 + ((i * 7 + j * 3) % 9 - 4) * 0.25 + (i >= 10 ? 0.5 : 0)).ToArray())];

Plt.Create()
    .WithTitle("Fill weight, mean of five")
    .WithSize(800, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .ShewhartChart(ControlChartData.XBar(subgroups), ControlRules.Nelson1 | ControlRules.Nelson2,
            new ControlLimits(Center: 50, Lower: 49.2, Upper: 50.8),
            parts => { parts.Values.Label = "subgroup mean"; parts.Violations.Label = "out of control"; })
        .SetXLabel("Subgroup")
        .SetXTickLocator(new MultipleLocator(5))
        .SetYLabel("Weight (g)")
        .WithLegend())
    .Save("xbar_chart.svg");

An X-bar chart: a blue line of twenty subgroup means between the dashed limits UCL 50.8 and LCL 49.2, with the subgroups 12, 19 and 20 marked in orange

Subgroup 12 is beyond the upper limit (test 1). The run above the centre line, from subgroup 11 on, reaches nine points at subgroup 19, and test 2 flags that point and each later one, so 19 and 20 are marked too. A value on a limit is not beyond it. The subgroup charts take subgroups of 2 to 10 values, with the constants A2, D3 and D4 tabulated for those sizes; the p and c charts take binomial and Poisson limits and stop a lower limit at 0. ControlChart draws what it always did.

Anchored VWAP and the volume profile. Vwap(bars, anchor) averages the typical price, (high + low + close) / 3, weighted by volume, from the bar you pick: before it there is no value, and at anchor 0 it is the cumulative VWAP. VolumeProfile(bars, bins) adds each bar's volume to the price bin its close falls in and draws the bins as horizontal bars on the secondary X axis.

using MatPlotLibNet;
using MatPlotLibNet.Indicators;

var random = new Random(7);
int bars = 60;
double[] close = new double[bars], high = new double[bars], low = new double[bars], volume = new double[bars];
double price = 100;
for (int i = 0; i < bars; i++)
{
    price += (random.NextDouble() - 0.5) * 2;
    close[i] = price;
    high[i] = price + random.NextDouble();
    low[i] = price - random.NextDouble();
    volume[i] = 1000 + random.Next(2000);
}

double[] index = [.. Enumerable.Range(0, bars).Select(i => (double)i)];
Plt.Create()
    .WithTitle("Volume by price, and the VWAP since bar 30")
    .WithSize(900, 500)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(index, close, line => line.Label = "Close")
        .Vwap(OhlcvBars.Hlcv(high, low, close, volume), anchor: 30, vwap => vwap.Label = "VWAP from bar 30")
        .VolumeProfile(OhlcvBars.Cv(close, volume))
        .SetXLabel("Bar")
        .SetYLabel("Price")
        .WithLegend())
    .Save("volume_profile.svg");

A price line over sixty bars, an orange VWAP that starts at bar 30, and green volume bars growing from the right edge

The orange line starts at bar 30, the anchor, and stands near 102.8 at the end. The green bars grow from the right edge of the plot and are as thick as the twelve price bins; the top axis counts volume, 0 at the right edge, and the longest bar, which stands against its 20 000 mark, covers a quarter of the plot's width. The streaming form is StreamingVwap, whose AnchorHere() makes the average restart at the next bar, and df.Vwap(highCol, lowCol, closeCol, volumeCol, anchor) is the DataFrame bridge. The library has 59 indicators now.

A neutral grey that the theme owns. Theme.NeutralColor is the grey of reference lines, levels, Fibonacci lines, trendlines, waterfall connectors, the stem baseline, a dumbbell's connector and the outlines of a map. A theme that sets none derives it from its plot: the library's grey where it stands at 3 : 1 or more against the plot, else the nearest grey that does. Nord, Dracula, Monokai, Gruvbox and OpsPanel, where the old grey stood at 2.3 to 2.9 : 1, get their own. ThemeBuilder has WithNeutralColor(color) to set one by hand. A dumbbell's connector and a map's outline take no colour slot, so the next series has the colour it would have had without them. See Styling.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] weekNumber = [1, 2, 3, 4, 5, 6, 7, 8];
double[] throughput = [3.1, 3.9, 3.4, 4.8, 5.2, 4.9, 6.3, 6.8];

Plt.Create()
    .WithTheme(Theme.Nord)
    .WithTitle("Reference lines under Nord")
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(weekNumber, throughput, s => s.Label = "Throughput")
        .AxHLine(5.0, line => line.Label = "Target")
        .AxHLine(3.0, line => line.Label = "Floor")
        .SetXLabel("Week")
        .WithLegend())
    .Save("neutral_colour.svg");

A line chart on Nord's dark background with two dashed grey reference lines, Target at 5 and Floor at 3

The two dashed lines are the neutral grey of the Nord theme, #8D8D8D, which stands at 3 : 1 against its plot; the grey fixed before this release, #808080, stood at 2.55 : 1. Under Theme.Default they are #808080 as ever.

The date of a calendar's first cell. HeatmapSeries.FirstCellDate holds the date of the cell in row 0, column 0, which CalendarHeatmap sets; the date of every other cell follows from its column and row. See Changed for the table.

Changed

A colour bar puts its ticks where the colours are. The bar beside a heatmap under a log normaliser used to space its ticks evenly in the value, so the tick 200 on a bar of 1 to 1000 stood at 0.199 of the bar while its cells are drawn in the colour at 0.767. The tick now stands where the colour is, the ticks crowd towards the top of the bar as the colours do, and a tick that would stand within one label of the one before it is left out. A normaliser that bands its colours has its ticks on the band boundaries. A spectrogram's bar names the range of its magnitudes, not 0 to 1. A linear bar draws as it did.

using MatPlotLibNet;
using MatPlotLibNet.Styling.ColorMaps;

double[,] counts = new double[6, 8];
for (int r = 0; r < 6; r++)
    for (int c = 0; c < 8; c++)
        counts[r, c] = Math.Pow(10, 2 + (r * 8 + c) / 48.0);

Plt.Create()
    .WithTitle("Requests per cell, 100 to 1000 on a log scale")
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Heatmap(counts)
        .WithColorMap("plasma")
        .WithNormalizer(new LogNormalizer())
        .WithColorBar(cb => cb with { Label = "requests" }))
    .Save("heatmap_log_colorbar.svg");

A heatmap in the plasma colour map, 100 to 1000, with a colour bar whose ticks 100, 200, 300, 400, 500, 700 and 900 crowd towards the top

The bar's ticks are 100, 200, 300, 400, 500, 700 and 900, and the gaps between them shrink towards the top as the colours of a log normaliser do; 100 stands at the bottom of the bar, where the darkest cell's colour is.

Saving to .png, .pdf or .gif without an exporter is refused. Save("chart.png") in a program that does not use MatPlotLibNet.Skia used to write an SVG document under the raster name and report success. It now throws an InvalidOperationException that names the package, before the file is opened, so a file that was there stays. A .gif is an animation's format, and its message sends you to SaveGif. Any other extension still writes SVG.

using MatPlotLibNet;

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

try
{
    Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Plot(x, y)).Save("chart.png");
}
catch (InvalidOperationException refusal)
{
    Console.WriteLine(refusal.Message);
}

Without the package this prints that a .png file needs the exporters of MatPlotLibNet.Skia, which is not loaded; with it referenced and one of its members called (for example figure.ToPng()), the file is written.

The strength of a light reaches what it lights. WithLighting(dx, dy, dz, ambient, diffuse) used two constants and shaded every face by the light's direction alone; the ambient and diffuse arguments changed nothing. The intensity is now ambient + diffuse · (dot + 1) / 2, held to 0 to 1, which at the defaults, 0.3 and 0.7, is matplotlib's own shading. A light of your own (ILightSource) shades a 3-D bar as it shades a surface. The scene group no longer carries data-light-*, data-face-normal and data-base-color attributes: no script read them. See Advanced.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] barColumn = [0, 1, 2, 0, 1, 2];
double[] barRow = [0, 0, 0, 1, 1, 1];
double[] barHeight = [3, 5, 2, 4, 6, 1];

Plt.Create()
    .WithTitle("A light with no ambient part: ambient 0, diffuse 1")
    .WithSize(640, 500)
    .AddSubPlot(1, 1, 1, ax => ax
        .WithCamera(elevation: 25, azimuth: -60)
        .WithLighting(dx: -0.4, dy: -0.8, dz: 0.45, ambient: 0.0, diffuse: 1.0)
        .Bar3D(barColumn, barRow, barHeight, s => { s.Color = Colors.Tomato; s.BarWidth = 0.6; }))
    .Save("light_direct.svg");

Plt.Create()
    .WithTitle("A light with no direct part: ambient 1, diffuse 0")
    .WithSize(640, 500)
    .AddSubPlot(1, 1, 1, ax => ax
        .WithCamera(elevation: 25, azimuth: -60)
        .WithLighting(dx: -0.4, dy: -0.8, dz: 0.45, ambient: 1.0, diffuse: 0.0)
        .Bar3D(barColumn, barRow, barHeight, s => { s.Color = Colors.Tomato; s.BarWidth = 0.6; }))
    .Save("light_ambient.svg");

Six tomato-coloured bars; the faces turned away from the light are almost black

The same six bars, every face in the same tomato colour

With no ambient part, the faces turned away from the light are almost black and the top faces are lit; with no direct part, every face has the same colour and the bars look flat. The defaults, 0.3 and 0.7, are in between, and are matplotlib's own shading.

A grid you changed stays a change. WithGrid is kept as a change over the theme's grid, so a figure written under one theme and read back under another keeps your change and takes the rest of the grid from the theme; a second WithGrid adds to the first. ShowGrid(false) under a theme that draws a grid hides it, where it was drawn anyway. The rings and spokes of a polar chart and the walls of a 3-D chart follow the same grid in colour, width and line style, where they were a fixed light grey half a pixel wide, and under matplotlib's classic theme a shown grid is classic's own: black, dotted, 0.5. See Styling.

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

double[] gridAngle = [.. Enumerable.Range(0, 100).Select(i => i * 2 * Math.PI / 100)];
double[] gridRadius = [.. gridAngle.Select(a => 1 + Math.Cos(3 * a))];

Plt.Create()
    .WithTitle("A polar chart on a dashed crimson grid")
    .WithSize(600, 600)
    .AddSubPlot(1, 1, 1, ax => ax
        .PolarPlot(gridRadius, gridAngle, s => { s.Color = Colors.Blue; s.LineWidth = 2; })
        .WithGrid(g => g with { Color = Colors.Crimson, LineStyle = LineStyle.Dashed }))
    .Save("polar_grid.svg");

A three-petalled blue curve on a polar axes whose rings and spokes are dashed crimson lines

The rings and the spokes are both dashed and crimson: a polar grid follows WithGrid in colour, width and line style, where it had been a fixed light grey half a pixel wide, and a dashed ring is a closed polyline, since a circle has no dash.

An inset is built where it stands. AddInset gives its callback a builder over the inset itself, so the legend, tick locators and formatters (an ITickFormatter of your own among them), scales, overlays and colour bar you set stay, the grid is the theme's of the figure it is drawn under, and an inset can hold an inset. See Advanced.

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

double[] signalX = [.. Enumerable.Range(0, 50).Select(i => i / 5.0)];
double[] signalY = [.. signalX.Select(v => Math.Sin(3 * v) * Math.Exp(-v / 4))];
double[] zoomX = [.. signalX.Where(v => v <= 1.5)];
double[] zoomY = [.. zoomX.Select(v => Math.Sin(3 * v) * Math.Exp(-v / 4))];

Plt.Create()
    .WithTitle("An inset that keeps its legend")
    .WithSize(750, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(signalX, signalY, s => s.Label = "Full signal")
        .WithLegend(LegendPosition.LowerLeft)
        .AddInset(0.5, 0.45, 0.45, 0.45, inset => inset
            .Plot(zoomX, zoomY, s => { s.Label = "Detail"; s.Color = Colors.Crimson; })
            .WithLegend()))
    .Save("inset_legend.svg");

A damped sine wave with the legend "Full signal" at the lower left and an inset of its first 1.5 units, whose own legend reads "Detail"

The inset has a legend of its own, "Detail", beside the main chart's "Full signal". The legend, tick locators and formatters, scales, overlays and colour bar of an inset are the ones its callback sets.

The tables of two charts say more. The table of a Marimekko chart has a row for each column, with its name, the edge it begins at, the edge it ends at and the height of each segment's cell, where it listed the points the chart draws. The table of a CalendarHeatmap has the date of every cell. See Accessibility.

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

DateTime[] days = [new(2025, 1, 6), new(2025, 1, 7), new(2025, 1, 8), new(2025, 1, 13)];
double[] commits = [3, 0, 5, 2];

var calendar = Plt.Create().AddSubPlot(1, 1, 1, ax => ax.CalendarHeatmap(days, commits)).Build();
Console.WriteLine(Csv(calendar));

var marimekko = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.Marimekko(
        ["Retail", "Online"],
        [new BarGroup("Brand A", [30, 15]), new BarGroup("Brand B", [20, 25])]))
    .Build();
Console.WriteLine(Csv(marimekko));

static string Csv(Figure figure) => string.Join("\n", figure.ToDataTables().Select(table => table.ToCsv().TrimEnd()));

The calendar's table starts with these rows, a date for every cell and the padding cells of the first and last week with no value:

row,column,date,value
Mon,0,2025-01-06,3
Mon,1,2025-01-13,2
Tue,0,2025-01-07,0
Tue,1,2025-01-14,NaN
Wed,0,2025-01-08,5

The Marimekko chart's table has a row for each column, in percent of the whole (Retail is 50 of 90, Online 40 of 90), and each segment's cell as a share of its column:

category,left,right,Brand A,Brand B
Retail,0,55.55555555555556,60,40
Online,55.55555555555556,100,37.5,62.5

The violin reads the Gaussian density. A violin's width at a height is the sample's Gaussian kernel density there (Silverman's bandwidth, the estimator of the density plot and the ridgeline) at 100 heights, scaled so that the widest point is the violin's width. On twelve observations the outline it replaces had five bumps and pinched to nothing; the density is one hump. A sample without spread is a spike. Every violin and raincloud picture changes.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[][] twelve = [[2.1, 2.4, 2.6, 2.9, 3.0, 3.1, 3.4, 3.6, 3.8, 4.2, 5.1, 6.3]];

Plt.Create()
    .WithTitle("A violin of twelve observations")
    .WithSize(500, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Violin(twelve, s => { s.Color = Colors.RebeccaPurple; s.Alpha = 0.6; })
        .SetYLabel("Value"))
    .Save("violin_density.svg");

A purple violin of twelve observations: one smooth hump, widest near 3, a thin tail up to 6.3

The outline is one smooth hump, widest near 3 where most of the observations stand, with a thin tail up to the largest, 6.3. The outline it replaces counted the observations in a window and had several bumps on a sample this small.

A regression band uses the exact Student t quantile. A 90 % band was drawn as wide as a 95 % one, because the quantile came from a table of five values; it is computed now, at every degree of freedom.

Axes that share a scale share its margins. An axes that shares X or Y with another read only its own series for its margins and sticky edges, so one value could stand at two pixels: a count 3.3 px off its curve on a risk table. Both axes now range, pad and clamp from the same series. A figure that shares a scale with an axes that has sticky edges (bars, an area, a histogram) and has none itself loses its margin on that side.

A relative rotation graph shows its centre. With the quadrant grid on, the range holds 100/100 where every asset leads or every asset lags, so the four quadrants and the crosshair stay in view. See RelativeRotationSeries.

using MatPlotLibNet;

int rotationWeeks = 80;
var rotationNoise = new Random(3);
double[] rotationBenchmark = new double[rotationWeeks];
double rotationLevel = 100;
for (int week = 0; week < rotationWeeks; week++)
{
    rotationLevel *= 1 + (rotationNoise.NextDouble() - 0.5) * 0.004;
    rotationBenchmark[week] = rotationLevel;
}

double[] Leader(double pull) =>
    [.. rotationBenchmark.Select((b, week) => b * (1 + pull * Math.Pow(week / (double)rotationWeeks, 2)))];

Plt.Create()
    .WithTitle("Three assets that all lead their benchmark")
    .WithSize(800, 650)
    .AddSubPlot(1, 1, 1, ax => ax
        .SetXLabel("RS-Ratio")
        .SetYLabel("RS-Momentum")
        .RelativeRotation([Leader(0.6), Leader(0.4), Leader(0.25)], rotationBenchmark, ["Alpha", "Beta", "Gamma"],
            s => s.TailLength = 12))
    .Save("rrg_centre.svg");

A relative rotation graph of three assets in the upper right quadrant, with the 100/100 centre and the three other quadrants in the lower left corner

All three assets lead, so their tails stand above 100 on both axes, yet the plot reaches 100 on both: the centre where the four quadrants meet is in the lower left corner, and the crosshair and the tinted quadrants are drawn. Before, the range ended at the tails and the plot showed one quadrant and no crosshair.

Vwap(prices, volumes) refuses arrays of different lengths instead of cutting to the shorter, as every other indicator has since 1.24.0.

1.24.0

Every Y axis of a chart now belongs to one container. The primary axis is the first axis of the left side, and each further axis stacks outward past the one before it, on either side and as many times as the chart needs one: WithAddRightYAxis and WithAddLeftYAxis add an axis with its own range, ticks, colour, series and legend entry, and the four scales 1.23.0 stopped at are no limit any more. The older verbs for a second and for an outward-stacked Y axis keep working and are marked obsolete in favour of these two.

The release also places the texts of a chart with more care: a legend that stands where it covers the fewest marks, annotations in the theme's text size that can be moved by a number of pixels or out of the way of the data, a bar's value label beyond the bar's end whichever way the bar points, and turned tick labels that have room beside the plot.

Thirteen more rows of the roadmap are done in the same release, each one rule where there had been two. A colour bar puts its ticks at round numbers, a polar axes has one radial scale, a filled contour fills its grid, a series that cannot be drawn from its data is refused when it is built, and a map keeps its holes, is coloured by a value, carries data and comes back from JSON.

Added

Any number of Y axes. Call WithAddRightYAxis or WithAddLeftYAxis once for each axis the chart has. Each call stacks one more axis on its side, past the ones already there, with nothing to count and no "second" or "third" in a name. On the left, the first axis you add stands next to the primary one, which stays the axis nearest the plot. The callback gets one builder that has the same verbs for every axis: SetYLabel, SetYLim, SetYAxisColor, Plot, Scatter, StreamingPlot and WithDirectLabel. This chart has five scales, the primary one and two more on each side, and WithYAxisTitleGap(8) leaves more room than the default 4 px between every title and its tick labels.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] hour = [0, 4, 8, 12, 16, 20, 24];
double[] requests = [120, 80, 340, 610, 560, 380, 150];
double[] cpu = [18, 12, 46, 82, 74, 51, 22];
double[] memory = [3.1, 3.0, 4.2, 6.8, 6.4, 4.9, 3.5];
double[] queue = [0, 0, 4, 31, 22, 6, 1];
double[] latency = [42, 40, 55, 130, 118, 71, 48];

var figure = Plt.Create()
    .WithSize(1000, 500)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, requests, s => { s.Label = "Requests"; s.Color = Colors.Crimson; })
        .SetXLabel("Hour of the day")
        .SetYLabel("Requests (per second)")
        .SetYAxisColor(Colors.Crimson)
        .WithAddRightYAxis(right => right
            .Plot(hour, cpu, s => { s.Label = "CPU"; s.Color = Colors.SteelBlue; })
            .SetYLabel("CPU (%)")
            .SetYAxisColor(Colors.SteelBlue))
        .WithAddRightYAxis(right => right
            .Plot(hour, memory, s => { s.Label = "Memory"; s.Color = Colors.ForestGreen; })
            .SetYLabel("Memory (GB)")
            .SetYAxisColor(Colors.ForestGreen))
        .WithAddLeftYAxis(left => left
            .Plot(hour, queue, s => { s.Label = "Queue"; s.Color = Css4Colors.DarkOrange; })
            .SetYLabel("Waiting jobs")
            .SetYAxisColor(Css4Colors.DarkOrange))
        .WithAddLeftYAxis(left => left
            .Plot(hour, latency, s => { s.Label = "Latency"; s.Color = Colors.RebeccaPurple; })
            .SetYLabel("Latency (ms)")
            .SetYAxisColor(Colors.RebeccaPurple))
        .WithYAxisTitleGap(8)
        .WithLegend())
    .TightLayout()
    .Build();

figure.Save("five_y_axes.svg");

Five Y axes around one plot: Requests on the primary axis with Waiting jobs and Latency stacked past it on the left, CPU and Memory stacked on the right, each axis in the colour of its own line

Requests, the primary axis, stands against the plot's left edge, and Waiting jobs and then Latency stack past it, further left; CPU stands against the right edge and Memory stacks past it. Every line peaks at noon, yet each is read on its own axis, with its own range, its own tick numbers and its own title in the colour of its line. The colour reaches a spine only on the first axis of a side, so the left spine is crimson and the right one steel blue; an axis stacked past it draws no spine of its own. Every title stands 8 px from its tick labels, on the left and on the right alike, and 8 px separate one axis from the next. Leave WithYAxisTitleGap out and both gaps are 4 px, matplotlib's axes.labelpad; a title's own Pad (the text style given to SetYLabel) adds on top of either. A browser draws the same SVG with the ink metrics of its own font, so there the two sides can differ by up to about 2 px. The legend lists all five lines, and a line on any of the five axes can be named at its end with WithDirectLabel.

Every axis reaches the JSON. A figure written with ToJson, and sent by REST, SignalR and the MCP server, keeps every axis, whatever their number, each with its own label, limits, ticks, colour and series.

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());
var axes = copy.SubPlots[0].YAxes;

Console.WriteLine($"{axes.Left.Count} left, {axes.Right.Count} right");
Console.WriteLine(copy.ToSvg() == figure.ToSvg());

It prints 3 left, 2 right, the primary axis counting as the first of the left, and True. A figure that stays within the four axes 1.23.0 allowed writes the same file as before. A 1.23.0 reader of a file with more axes keeps the first two on the right and the first one past the primary on the left and draws exactly what this version draws of that smaller figure; the axes past those, and their series, are left out and never merged onto a wrong axis.

Annotations that move out of the way, and by a number of pixels. Set AvoidOverlap on an annotation and its text moves clear of the marks the chart drew, the legend and the other annotations, to the nearest of a fixed list of places that covers the fewest; when it moved, a thin line joins it to its point, and an annotation with an arrow needs none. An annotation that does not set it stands where it is anchored, as before. In this chart the legend stands in the upper right, the line climbs through the points, and two notes lie almost on one point:

using MatPlotLibNet;
using MatPlotLibNet.Models;

double[] ramp = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

Plt.Create()
    .WithSize(700, 420)
    .AddSubPlot(1, 1, 1, ax => ax
        .WithTitle("Annotations that move out of the way")
        .Plot(ramp, ramp, line => { line.Label = "Growth"; })
        .WithLegend(LegendPosition.UpperRight)
        .Annotate("Start", 1.0, 1.1, ann => ann.AvoidOverlap = true)
        .Annotate("Halfway", 5.0, 5.0, ann => ann.AvoidOverlap = true)
        .Annotate("Halfway, noted twice", 5.1, 5.15, ann => ann.AvoidOverlap = true)
        .Annotate("Peak", 9.0, 9.0, ann =>
        {
            ann.AvoidOverlap = true;
            ann.ArrowTargetX = 9.0;
            ann.ArrowTargetY = 9.0;
        }))
    .Save("annotations_avoid_overlap.svg");

A rising line with four notes, each beside its point, clear of the line and the legend, and joined to its point by a thin line

The notes stand beside their points and not on the line; the two near the middle stand apart, one below its point and one above and to the right of it, and "Peak" keeps the arrow that points at its point. An annotation can also be put where you want it with OffsetX and OffsetY, a number of screen pixels from its point (OffsetY counts downward, as the picture does), and VerticalAlignment says which part of the text stands on the point: Baseline as before, Center, Top or Bottom.

using MatPlotLibNet;
using MatPlotLibNet.Rendering;

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

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(x, y)
        .Annotate("Launch", 3.0, 5.0, ann =>
        {
            ann.OffsetX = 12;
            ann.OffsetY = -10;
            ann.VerticalAlignment = TextVerticalAlignment.Bottom;
        }))
    .Save("annotation_offset.svg");

The note "Launch" stands 12 px to the right of its point and 10 px above it, its text standing on that place.

Bars stacked on both sides of zero. A stacked bar chart keeps two running sums, one for the layers at or above zero and one for the layers below it, so a value below zero grows down from zero whatever the layers above it add up to. A Likert chart, the answers that disagree below the middle line and the answers that agree above it, is one Bar call for each answer and SetBarMode(BarMode.Stacked):

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

string[] questions = ["Easy to use", "Fast", "Reliable", "Good value", "Would recommend"];
double[] stronglyDisagree = [-6, -4, -3, -10, -5];
double[] disagree = [-12, -8, -6, -15, -9];
double[] neutral = [14, 12, 10, 18, 11];
double[] agree = [41, 44, 47, 38, 43];
double[] stronglyAgree = [27, 32, 34, 19, 32];

Plt.Create()
    .WithTitle("How users rate the product (share of answers, %)")
    .WithSize(900, 480)
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax
        .Bar(questions, stronglyDisagree, s => { s.Label = "Strongly disagree"; s.Color = Color.FromHex("#B2182B"); })
        .Bar(questions, disagree, s => { s.Label = "Disagree"; s.Color = Color.FromHex("#EF8A62"); })
        .Bar(questions, neutral, s => { s.Label = "Neutral"; s.Color = Color.FromHex("#D9D9D9"); })
        .Bar(questions, agree, s => { s.Label = "Agree"; s.Color = Color.FromHex("#67A9CF"); })
        .Bar(questions, stronglyAgree, s => { s.Label = "Strongly agree"; s.Color = Color.FromHex("#2166AC"); })
        .SetBarMode(BarMode.Stacked)
        .AxHLine(0, line => { line.Color = Colors.Black; line.LineWidth = 1; })
        .SetYLabel("Share of answers (%)")
        .WithLegend(LegendPosition.OutsideRight))
    .Save("likert_diverging_bars.svg");

Five stacked columns, one for each question: the two disagreeing answers, in red and orange, stand below the zero line, the strongest next to it, and the neutral, agreeing and strongly agreeing answers, in grey and two blues, stand above it

Each column has its two disagreeing answers below the zero line and its three other answers above it, each starting where the one before it ends on its side. The axis reaches from the lowest foot to the highest end. A value of zero goes with the answers above, and a value that is not a number adds to neither sum.

Polar bars that stack. Set the bar mode of a polar axes to stacked and its PolarBar series stand on one another, wedge by wedge, each starting where the one below it ends, and the rings reach the top of the highest stack. A wind rose of the share of the hours by direction, in three classes of wind speed, is three PolarBar calls:

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

double[] bearings = [0, 45, 90, 135, 180, 225, 270, 315];
double[] theta = [.. bearings.Select(b => (90 - b) * Math.PI / 180)];
double[] calm = [3.1, 2.4, 4.0, 2.1, 3.0, 4.4, 3.3, 2.2];       // % of the hours, under 5 m/s
double[] breeze = [4.2, 3.6, 5.5, 2.9, 4.1, 6.0, 4.5, 3.2];     // 5 to 10 m/s
double[] strong = [2.2, 1.8, 3.3, 1.3, 2.3, 3.7, 2.3, 1.7];     // over 10 m/s

Plt.Create()
    .WithTitle("Wind Rose — share of the hours by direction and speed")
    .WithSize(640, 640)
    .AddSubPlot(1, 1, 1, ax => ax
        .PolarBar(calm, theta, s => { s.Color = Color.FromHex("#9ECAE1"); s.BarWidth = 0.9 * Math.PI / 4; s.Label = "under 5 m/s"; })
        .PolarBar(breeze, theta, s => { s.Color = Color.FromHex("#3182BD"); s.BarWidth = 0.9 * Math.PI / 4; s.Label = "5 to 10 m/s"; })
        .PolarBar(strong, theta, s => { s.Color = Color.FromHex("#08306B"); s.BarWidth = 0.9 * Math.PI / 4; s.Label = "over 10 m/s"; })
        .SetBarMode(BarMode.Stacked)
        .WithLegend())
    .Save("wind_rose_stacked.svg");

A wind rose of eight wedges, each made of three layers, the palest nearest the centre and the darkest outermost, with rings labelled 2.5, 5.0, 7.5, 10.0 and 12.5

Each of the eight directions has one wedge of three layers, the palest nearest the centre. The stacking is worked out when the chart is drawn and written into no series, so the JSON of the figure is the JSON of three overlaid bar series. One bar series alone is not a stack, and a radius that is not drawn (missing, infinite or below zero) adds nothing.

The layers of a stack plot are named in the legend. Labels names the layers of a StackPlot, and each name is an entry of the legend in the colour its layer is drawn in; in an interactive chart a click on any of them hides or shows the whole stack. The stack holds one colour of the cycle for each layer, so the line drawn after it takes the fourth colour, as in matplotlib, and not one a layer already wears. A stack with no layer names keeps one entry under the series' name.

using MatPlotLibNet;
using MatPlotLibNet.Models;

double[] months = Enumerable.Range(1, 12).Select(m => (double)m).ToArray();
double[][] layers =
[
    [.. months.Select(m => 20 + 2 * m)],
    [.. months.Select(m => 15 + 3 * Math.Sin(m / 2.0) + m)],
    [.. months.Select(m => 10 + 1.5 * m)],
];
double[] target = [.. months.Select(m => 55 + 3.5 * m)];

Plt.Create()
    .WithTitle("Revenue by product line against the target")
    .WithSize(900, 480)
    .AddSubPlot(1, 1, 1, ax => ax
        .StackPlot(months, layers, s => s.Labels = ["Hardware", "Software", "Services"])
        .Plot(months, target, s => { s.Label = "Target"; s.LineWidth = 2; })
        .SetXLabel("Month")
        .SetYLabel("Revenue (k€)")
        .WithLegend(LegendPosition.UpperLeft))
    .Save("stackplot_legend_line.svg");

Three stacked areas, blue, orange and green, with a red line above them; the legend lists Hardware, Software, Services and Target

The legend lists the three layers in their own colours and the target in the colour after them, red. A pie, a donut and a funnel take one colour of the cycle for each part in the same way, so a pie drawn after a line no longer repeats the line's colour.

A map coloured by a value. Choropleth colours each country by a value through a colour map. The values are joined to the countries of the built-in Natural Earth set by their three-letter code (ADM0_A3: FRA, NOR, and so on), or Choropleth("POP_EST") reads a numeric property of the features, and WithColorBar() shows what the colours mean. This map colours each country by the base-10 logarithm of its population:

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

var populationOf = NaturalEarth110m.Countries()
    .Where(country => country.NumberOf("POP_EST") > 0)
    .ToDictionary(country => country.Properties["ADM0_A3"], country => Math.Log10(country.NumberOf("POP_EST")));

var figure = Plt.Create()
    .WithTitle("Population by country (log10)")
    .WithSize(1000, 560)
    .AddSubPlot(1, 1, 1, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Ocean(Color.FromHex("#dfeaf5"))
        .Choropleth(populationOf, ColorMaps.Plasma)
        .WithColorBar()
        .WithLegend(visible: false))
    .Build();

figure.Save("choropleth_world.svg");

A world map in the Robinson projection with each country in a colour from purple to yellow, India and China the lightest, and a colour bar on the right whose ticks read 3, 4, 5, 6, 7, 8 and 9

GeoFeature.NumberOf(property) reads a property as a number, invariantly (a point is the decimal separator whatever the culture of the process), and gives NaN where the feature has none; a country with no value is drawn in the colour map's bad colour, and the bar reads the range of the values that can be drawn. The map has one projection, the one WithProjection gave: Ocean, Land, Borders, Coastlines and every layer below read it, and a layer asked for before the axes has one is refused with "call WithProjection first".

A map comes back from JSON. The map writes its projection, with 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 set, or a GeoJSON FeatureCollection with the holes kept for features of your own), its values, its colour map and its normaliser, into the package data of the series:

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());

Console.WriteLine(copy.ToSvg() == figure.ToSvg());

It prints True. A process that reads such JSON before it has used any type of MatPlotLibNet.Geo calls GeoPolygonSeries.RegisterReader() once. A 1.23.0 reader leaves the map out with the diagnostic for a series kind it does not know, and the rest of the figure is intact.

Data on a map. Points and Bubbles mark places by latitude and longitude; the area of a bubble is in proportion to its value, the largest 400 square points, as a scatter's size is. A place the projection cannot place, such as the far side of an orthographic globe, is left out:

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

double[] cityLat = [35.68, 28.61, 31.23, -23.55, 19.43, 30.04, 19.08, 39.90, 23.81, 40.71, 6.52, 41.01, 51.51, -33.87];
double[] cityLon = [139.69, 77.21, 121.47, -46.63, -99.13, 31.24, 72.88, 116.41, 90.41, -74.01, 3.38, 28.98, -0.13, 151.21];
double[] cityMillions = [37.4, 31.0, 27.1, 22.0, 21.8, 21.3, 20.7, 20.5, 21.0, 18.8, 15.4, 15.5, 9.5, 5.3];

Plt.Create()
    .WithTitle("Largest cities — population in millions")
    .WithSize(1000, 520)
    .AddSubPlot(1, 1, 1, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Ocean(Color.FromHex("#1a3a5c"))
        .Land(Color.FromHex("#2d5a27"))
        .Borders(Color.FromHex("#4c7a45"), 0.2)
        .Bubbles(cityLat, cityLon, cityMillions, s => { s.Color = Color.FromHex("#FFB000"); s.Alpha = 0.75; })
        .WithLegend(visible: false))
    .Save("bubble_map.svg");

A dark green world map on a dark blue ocean with fourteen amber bubbles at the largest cities, Tokyo's the largest

HexBins(latitudes, longitudes) bins places into hexagons in the plane of the map, and Flows draws a line along the great circle between two places for each GeoFlow, as wide as its quantity. A flow that crosses the antimeridian is cut where it leaves the map on one side and starts again on the other:

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

GeoFlow[] routes =
[
    new(new GeoCoordinate(35.68, 139.69), new GeoCoordinate(37.77, -122.42), 9),   // Tokyo to San Francisco
    new(new GeoCoordinate(51.51, -0.13), new GeoCoordinate(40.71, -74.01), 7),     // London to New York
    new(new GeoCoordinate(-33.87, 151.21), new GeoCoordinate(51.51, -0.13), 4),    // Sydney to London
    new(new GeoCoordinate(-23.55, -46.63), new GeoCoordinate(6.52, 3.38), 3),      // São Paulo to Lagos
    new(new GeoCoordinate(31.23, 121.47), new GeoCoordinate(25.2, 55.27), 5),      // Shanghai to Dubai
];

Plt.Create()
    .WithTitle("Freight routes — width by tonnes")
    .WithSize(1000, 520)
    .AddSubPlot(1, 1, 1, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Ocean(Color.FromHex("#1a3a5c"))
        .Land(Color.FromHex("#2d5a27"))
        .Flows(routes, Color.FromHex("#FFB000"), maxWidth: 4)
        .WithLegend(visible: false))
    .Save("flow_map.svg");

A dark green world map with five amber curves between ports, the widest from Tokyo to San Francisco, which leaves the map on the right and starts again on the left

The widest line is the largest flow, Tokyo to San Francisco: its two pieces stand at the two edges of the map, because the shortest path between them crosses the Pacific.

Countries resized by a value, and a bubble at each. Cartogram resizes each country so that its area is in proportion to its value without moving its centre, and the countries together keep the area they had; a country with no value above zero is left out. Dorling puts a bubble at the centre of each country instead:

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

var populationOf = NaturalEarth110m.Countries()
    .Where(country => country.NumberOf("POP_EST") > 0)
    .ToDictionary(country => country.Properties["ADM0_A3"], country => country.NumberOf("POP_EST"));

Plt.Create()
    .WithTitle("Population — countries resized, and a bubble at each")
    .WithSize(1000, 960)
    .AddSubPlot(2, 1, 1, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Ocean(Color.FromHex("#dfeaf5"))
        .Cartogram(populationOf, s => { s.Color = Color.FromHex("#d95f02"); s.StrokeColor = Colors.White; })
        .WithLegend(visible: false))
    .AddSubPlot(2, 1, 2, ax => ax
        .WithProjection(GeoProjection.Robinson)
        .Ocean(Color.FromHex("#dfeaf5"))
        .Land(Color.FromHex("#cfcfcf"))
        .Dorling(populationOf, s => { s.Color = Color.FromHex("#d95f02"); s.Alpha = 0.7; })
        .WithLegend(visible: false))
    .Save("cartogram_dorling.svg");

Two world maps: above, the countries drawn in orange at a size in proportion to their population; below, the grey countries with an orange bubble at the centre of each, the largest two in India and China

The cartogram is a non-contiguous one: each country keeps its place and its shape and only its size changes. A country with no value above zero, or that is not in the dictionary, is not drawn. A layer asked for before the axes has a projection is refused with "call WithProjection first" and leaves the axes as it was.

Changed

A legend left at Best stands where it covers the fewest marks. WithLegend() with no position, like LegendPosition.Best itself, used to put the legend in the upper right corner whatever the data. It now tries the ten places matplotlib's loc='best' tries, in its order, and takes the first one with the lowest score: the vertices of lines, the centres of points, the bars and boxes and the paths a segment of which crosses the legend's box, hidden series and annotation texts included. On a chart whose line climbs from the lower left into the upper right the legend now stands in the upper left. Cartesian, polar and 3-D axes look for room; the legend of a map keeps the upper right corner. A position you name is kept, and the classic theme keeps matplotlib's classic default, the upper right corner, through Theme.DefaultLegendPosition.

Annotations and other texts take the theme's text size. An annotation was drawn at size 10 under every theme; its text now follows the theme as tick labels do, and its Font still overrides it. The labels of a radar chart, the percentages of a pie, the node names of a network graph, a funnel's labels and the value labels of bars follow it too, and the start names of a slope graph are centred on their markers at the size of its end names.

A bar's value label stands beyond the bar's end. With ShowLabels, the value of a bar that rises stands above its end and the value of a bar that falls stands below its end, to the right or to the left of it on a horizontal bar, 4 px from the end, on an axis that runs forwards and on one that runs backwards. Before, the label stood 4 px above the end on the screen whichever way the bar pointed, which put the label of a bar below zero over the zero line, and, on an axis that runs backwards, inside the bars.

using MatPlotLibNet;

string[] groups = ["North", "South", "East", "West"];
double[] change = [3.0, -2.0, 5.0, -4.0];

Plt.Create()
    .WithSize(800, 380)
    .AddSubPlot(1, 2, 1, ax => ax
        .WithTitle("Axis running up")
        .Bar(groups, change, s => s.ShowLabels = true)
        .SetYLim(-6, 8))
    .AddSubPlot(1, 2, 2, ax => ax
        .WithTitle("Axis running down")
        .Bar(groups, change, s => s.ShowLabels = true)
        .SetYLim(8, -6))
    .Save("bar_labels_beyond_the_end.svg");

Two bar charts of four bars: in the left one the labels of North and East stand above their bars and of South and West below theirs; in the right one, whose axis runs down, each label stands beyond the end of its bar again

In both charts each label stands beyond the end of its bar, in the direction the bar points in the data: North and East, which rise from zero, have theirs past the end they rise to, and South and West, which fall, past the end they fall to. On the right, where the axis runs down, the end North and East rise to is the lower one.

Turned tick labels have room beside the plot. The layout measures a turned X tick label at its own rotation beside the plot as well as below it, and a Y tick label at its own rotation: the first label of a crowded bottom axis, hung from its end, no longer passes the left edge of the figure, and the last no longer passes the right edge. The margin, or the gutter between two plots, grows by what stands out, and a figure whose labels stay inside its plot is laid out as before.

A Y axis title stands the same distance from its tick labels on both sides. For every Y axis, the primary one included, the gap between the ink of the title and the ink of the widest tick label is now 4 px by default, set for the whole chart with WithYAxisTitleGap(pixels) as in the example above. Before, a left title stood 12 px from its labels and a right one 4 px at the theme's default size, and at 18 pt the right title touched its labels. At the default size a left-hand title now stands about 8 px closer to its tick labels and a right-hand one where it was. The layout reserves exactly the room the axes draw, in the default layout as well as the tight one: a left-hand title is no longer cut off at the edge of the figure, a figure whose labels did not fit its margin or the gutter between subplots gets a wider one (a two-column grid's 40 px gutter becomes 44 px), and one that reserved too much gets a narrower one.

Every Y axis draws the way the primary one does. An axis added past the primary takes its own scale, tick locator or tick spacing and minor ticks, and a title with math markup ($\alpha$ rate) is drawn as math on every axis. Before, every axis past the primary was drawn on a linear scale whatever its setting. The builder of an added axis has no verb for the scale, which is a setting of the axis itself, reached through the container: YAxes.Left[0].Axis is the primary Y axis, YAxes.Left[1].Axis the first axis added on the left, YAxes.Right[0].Axis the first one on the right, and so on. Here the right axis is put on a log scale:

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

double[] day = [1, 2, 3, 4, 5, 6, 7];
double[] visitors = [120, 135, 128, 150, 162, 171, 158];
double[] signups = [3, 12, 40, 150, 610, 2300, 9100];

var figure = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(day, visitors, s => s.Color = Colors.Crimson)
        .SetYLabel("Visitors")
        .SetXLabel("Day")
        .WithAddRightYAxis(right => right
            .Plot(day, signups, s => s.Color = Colors.SteelBlue)
            .SetYLabel("Sign-ups")))
    .Build();

figure.SubPlots[0].YAxes.Right[0].Axis.Scale = AxisScale.Log;
figure.Save("log_right_axis.svg");

Visitors on a linear left axis from 120 to 180, and sign-ups on a log right axis whose ticks read 10 to the power 1 up to 10 to the power 4

The crimson visitors line is read on the linear left axis, 120 to 180. The steel blue sign-ups line grows about fourfold a day, so on the log axis on the right, with its ticks at 10¹, 10², 10³ and 10⁴, it is nearly straight; on a linear axis it would lie flat along the bottom until the last two days.

A colour bar's ticks are round numbers. The bar asks a tick locator for its ticks, as an axis does, and takes as many as fit along its length (a vertical bar one for every two lines of the tick font, a horizontal one for every three, from 2 to 9). A bar for 0 to 11 reads 0, 2, 4, 6, 8 and 10 where it read 0.0, 2.2, 4.4, 6.6, 8.8 and 11.0; the bar beside the map above reads 3, 4, 5, 6, 7, 8 and 9. The labels share one number format, a tick stands where its value is on the gradient, the bar of a figure reads the same ticks as the bar of an axes, and the layout reserves room for the widest label the bar can carry. The bar reads the series that has values: the ocean or land of a map, a plain fill, backs none.

A filled contour fills its grid in bands. ContourSeries with Filled set and Contourf fill the bands between the levels by one rule, over the points of the grid, with the lines as their edges. The fill follows the axes' range and the limits you set. Before, the colour backdrop of a filled line contour covered the whole plot while its lines moved with a limit, and the lowest band of a Contourf covered the whole plot rectangle:

using MatPlotLibNet;

double[] gx = Enumerable.Range(0, 41).Select(i => -4.0 + i * 0.2).ToArray();
double[] gy = Enumerable.Range(0, 41).Select(i => -4.0 + i * 0.2).ToArray();
var gz = new double[41, 41];
for (int r = 0; r < 41; r++)
    for (int c = 0; c < 41; c++)
        gz[r, c] = Math.Exp(-((gx[c] - 0.8) * (gx[c] - 0.8) + (gy[r] + 0.4) * (gy[r] + 0.4)) / 3)
                   - 0.6 * Math.Exp(-((gx[c] + 1.4) * (gx[c] + 1.4) + (gy[r] - 1.2) * (gy[r] - 1.2)) / 2);

Plt.Create()
    .WithTitle("A filled contour cut to a window")
    .WithSize(1000, 460)
    .AddSubPlot(1, 2, 1, ax => ax
        .WithTitle("Contour, Filled")
        .Contour(gx, gy, gz, s => { s.Filled = true; s.Levels = 8; })
        .SetXLim(-2, 2).SetYLim(-2, 2)
        .WithColorMap("viridis"))
    .AddSubPlot(1, 2, 2, ax => ax
        .WithTitle("Contourf")
        .Contourf(gx, gy, gz, s => s.Levels = 8)
        .SetXLim(-2, 2).SetYLim(-2, 2)
        .WithColorMap("viridis"))
    .Save("contour_filled_limits.svg");

Two panels of the same field cut to the window from -2 to 2: the left one a filled line contour with the level lines drawn as the edges of its bands, the right one a Contourf without them

Both panels fill the whole window, and the left one draws the level lines the bands end at. A cell with a value that cannot be drawn is left empty.

A polar axes has one radial scale. The grid and every polar series, lines, points, bars and heatmaps, read one scale. The rings stand at round numbers, labelled in one number format (or by the axis' own formatter) at 22.5° so the labels stay clear of the bars of a wind rose, and a ring labelled 10 stands exactly where a radius of 10 is drawn, whichever kind of series draws it. The scale reaches 5 % beyond the farthest radius, or exactly the limit you gave the axes; a radius below zero is left out. The wind rose above, whose largest stack reaches 14.1, reads 2.5, 5.0, 7.5, 10.0 and 12.5. Every polar picture changes.

The density curve reaches past the sample. A Kde curve is drawn from three bandwidths below the smallest value to three above the largest, and its axes take that extent, so the tails fall to zero inside the plot. The curve of 1, 2, 3, 4, 5, 6 and 30 used to run 82.66 px beyond the plot on each side in a 500 × 350 figure.

Series that draw without axes say so. A stat tile, a donut, a funnel, a gauge, a progress bar, a bullet graph, a sparkline, a Sankey, a clustermap and a pair grid draw no grid, ticks or frame, as a radar, a pie, a treemap, a table and a tree grid already did. A KPI tile in a mosaic built by hand needs no HideAllAxes() any more (the call still works), and a chart that mixes such a series with one that needs its axes keeps them.

A series that cannot be drawn is refused when it is built. Arrays that do not pair, a grid that is not one row for each y and one column for each x, a mesh without one edge more than its cells, a Sankey link to a node that is not there, a stack plot layer of another length than its x values and a 2-D histogram of fewer than one bin throw an ArgumentException at the verb, in one wording, where they used to be read and then fail while the chart was drawn. A figure read from JSON builds through the same verbs, so it is refused when it is read, and the MCP server names the place of the series in the spec:

using MatPlotLibNet;

double[] x = [0, 1, 2];
double[] y = [0, 1];
double[,] z = new double[3, 3];

try
{
    Plt.Create().AddSubPlot(1, 1, 1, ax => ax.Surface(x, y, z)).Build();
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}

It prints Array lengths must match. The values have 3 rows and 3 columns; the axes give 2 y and 3 x values. A regression or a residual plot keeps its tolerance: what does not pair is skipped and reported through the chart diagnostics. The MCP server accepts a stacked-area spec (x values and layers, no y values), which it used to refuse.

The legend of a state timeline wears its states. One entry, under the series' name, with a stripe for each distinct state in the order the states first appear, each in the fill the timeline draws that state with: its colour and the hatch an explicit pattern or a stated condition gives it.

A map keeps its holes and its frame. A country with a hole (Lesotho inside South Africa) is one path with the hole left unfilled, where its ring used to be filled as a country of its own on top. The map's range is the projection's bounds exactly, so a mark placed in projected coordinates by another series stands on the pixel the polygon of the same coordinates is drawn at (it stood about 19 px off). Land and Ocean are <path> elements in the SVG now.

Obsolete verbs and what takes their place

Every verb, builder, accessor and type that put a second, third or fourth Y axis on a subplot before the container is marked obsolete with a compiler warning, not an error. Code written against 1.23.0 or earlier compiles, draws the same SVG and writes the same JSON, and each warning names its replacement:

Obsolete Use
WithSecondaryYAxis(right => ...) WithAddRightYAxis(right => ...); the old verb configures the first right axis and never stacks, where the new one adds an axis on every call
WithOutwardYAxis(AxisSide.Right, ...) and (AxisSide.Left, ...) WithAddRightYAxis(...) and WithAddLeftYAxis(...)
SecondaryAxisBuilder and OutwardYAxisBuilder YAxisSlotBuilder, with every verb of both and SetYAxisColor on every axis
Axes.SecondaryYAxis and Axes.SecondarySeries Axes.YAxes.Right[0].Axis and Axes.YAxes.Right[0].Series
Axes.TwinX() Axes.YAxes.Right.FirstOrAdd(); Axes.YAxes.Right.Add() stacks another
Axes.RightYAxes and Axes.LeftYAxes Axes.YAxes.Right and Axes.YAxes.Left, whose slot 0 is the primary Y axis
Axes.AddOutwardYAxis(side) and Axes.AddSecondarySeries(series) Axes.YAxes.Right.Add() or Axes.YAxes.Left.Add(), and Axes.YAxes.Right.FirstOrAdd().AddSeries(series)
PlotSecondary(x, y) and ScatterSecondary(x, y) WithAddRightYAxis(right => right.Plot(x, y)) and right.Scatter(x, y)

The fixes that ask nothing new of your code are in the changelog: a left-hand line that asked for an end label lost its name altogether and now gets one, a labelled series on the top axis appears in the legend, and a chart session that applies zoom and pan over SignalR no longer ends for good when one event cannot be applied.

1.23.0

A technical indicator that reads a candle's open, high, low, close and volume used to take those as separate array parameters; it now takes one OhlcvBars value, built with the factory that names the columns the indicator actually uses.

using MatPlotLibNet;
using MatPlotLibNet.Indicators;

double[] close = [.. Enumerable.Range(0, 60).Select(i => 100 + 5 * Math.Sin(i / 6.0) + i * 0.2)];
double[] open  = [.. close.Select((c, i) => i == 0 ? c : close[i - 1])];
double[] high  = [.. close.Select((c, i) => Math.Max(c, open[i]) + 1)];
double[] low   = [.. close.Select((c, i) => Math.Min(c, open[i]) - 1)];

Plt.Create()
   .WithSize(800, 500)
   .AddSubPlot(2, 1, 1, ax => ax.Candlestick(open, high, low, close))
   .AddSubPlot(2, 1, 2, ax => ax.WilliamsR(OhlcvBars.Hlc(high, low, close)))
   .TightLayout()
   .Save("williams_r_ohlcv_bars.svg");

A candlestick chart of a sine-drift close price, with a Williams %R panel beneath it

WilliamsR pins its own axes to [-100, 0] — a candlestick and a bounded oscillator never share one Y-scale, so they take their own subplot each, the panel below reading the same OhlcvBars.Hlc the top one plots directly. The chart is identical to the one WilliamsR(high, low, close) drew before — OhlcvBars.Hlc wraps the same three arrays, checking they are the same length before the indicator ever sees them. An indicator that only reads two of the five columns (ForceIndex, Vpin) takes OhlcvBars.Cv(close, volume); one that reads four (KlingerVolumeOscillator, TwiggsMoneyFlow) takes OhlcvBars.Hlcv(high, low, close, volume). Underneath, the close to a hundred chart verbs the fluent API builds from now live one-per-file as extension members on Axes instead of sharing one class — a file-layout refactor only; no call in this example, or any other, changed shape because of it.

Four Y axes, two to a side. A chart with more than two scales stacks one more Y axis past the one before it, on either side: WithAddRightYAxis adds an axis on the right and WithAddLeftYAxis one on the left, each with its own range, ticks and series, the way matplotlib moves a twin's spine outward (twinx() and ax.spines['right'].set_position(('outward', N)), in one verb). The primary axis is the first axis of the left side, so the first axis WithAddLeftYAxis adds stands next to it. This chart has the primary axis and one more on the left and two on the right, four scales in all; 1.23.0 stopped there, and 1.24.0 has no such limit. The older verbs that stacked these axes, WithSecondaryYAxis and WithOutwardYAxis, are obsolete now: the two verbs above replace them, and the 1.24.0 section lists what takes the place of each.

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

double[] hour = [0, 4, 8, 12, 16, 20, 24];
double[] temperature = [12, 10, 14, 21, 23, 17, 13];
double[] humidity    = [88, 92, 75, 52, 47, 63, 81];
double[] windSpeed   = [8, 6, 9, 14, 18, 11, 7];
double[] pressure    = [1013, 1015, 1012, 1008, 1006, 1010, 1014];

Plt.Create()
    .WithSize(800, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, temperature, s => { s.Label = "Temperature"; s.Color = Colors.Crimson; })
        .SetYLabel("Temperature (°C)")
        .SetYAxisColor(Colors.Crimson)
        .SetXLabel("Hour of the day")
        .WithAddRightYAxis(right => right
            .Plot(hour, humidity, s => { s.Label = "Humidity"; s.Color = Colors.SteelBlue; })
            .SetYLabel("Relative humidity (%)")
            .SetYAxisColor(Colors.SteelBlue))
        .WithAddRightYAxis(outer => outer
            .Plot(hour, windSpeed, s => { s.Label = "Wind speed"; s.Color = Colors.ForestGreen; })
            .SetYLabel("Wind speed (km/h)"))
        .WithAddLeftYAxis(left => left
            .Plot(hour, pressure, s => { s.Label = "Pressure"; s.Color = Css4Colors.DarkOrange; })
            .SetYLabel("Pressure (hPa)"))
        .WithLegend())
    .TightLayout()
    .Save("four_axes.svg");

Four Y axes around one plot: Pressure stacked past Temperature on the left, Wind speed stacked past Humidity on the right, each in its own colour

Temperature, the primary axis, stands against the plot's left edge and Pressure stacks past it, further left; Humidity stands against the right edge and Wind speed stacks past it. Every title stands 4 px from its tick labels, the same on the left as on the right, and 4 px separate one axis from the next. A series on any of the four axes keeps its own legend entry, its own colour slot, and restores through JSON whatever kind it is — a bar or an area works here exactly as a line does. Zoom, pan and Home move every Y axis' own range together, each by its own proportional amount.

1.22.0

Several series can now share one colour from the theme's cycle instead of each taking the next one. A verb that draws several series as one group asks the axes for a fresh colour group and sets it on each series it adds; every renderer and the legend's swatch read the group, and a series with no group still takes the theme's next colour exactly as before.

Added

A Kaplan-Meier curve's confidence band and censor marks. The same KaplanMeier call from 1.20.0 now also draws a Greenwood confidence band around each curve and a mark at every time a subject left without an event, both in the curve's own colour — no new argument to pass.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

SurvivalGroup[] arms =
[
    new("Treatment", [6, 7, 10, 15, 19, 25, 32, 34], [true, true, false, true, false, true, false, false]),
    new("Placebo", [1, 2, 3, 4, 5, 8, 8, 11], [true, true, true, false, true, true, true, true]),
];

Plt.Create()
    .WithSize(600, 400)
    .WithTitle("Time to relapse")
    .AddSubPlot(1, 1, 1, ax => ax
        .KaplanMeier(arms)
        .SetXLabel("Weeks"))
    .Save("relapse_kaplan_meier_band.svg");

The same two step curves as 1.20.0, now each inside a shaded band, with a + mark at every censoring time

The shaded band around each curve is its Greenwood confidence interval, widest where the fewest subjects are still at risk; the + marks (at week 19 and 32 on the treatment curve, week 4 on the placebo curve) stand at the curve's own level at the moment a subject left without an event. Curve, band and marks share one colour group, so they read as the one group's survival estimate.

A raincloud. Raincloud draws a half violin, a narrow box and every group's raw, jittered points side by side at the group's own index, all three sharing one colour. A group is a NamedSample, exactly as Ridgeline takes them.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

NamedSample[] routes =
[
    new("Highway", [22, 24, 23, 26, 25, 28, 24, 23, 27, 25, 30, 26]),
    new("Backroads", [18, 29, 15, 35, 20, 32, 17, 24, 21, 30, 16, 27]),
];

Plt.Create()
    .WithSize(600, 400)
    .WithTitle("Commute time by route")
    .AddSubPlot(1, 1, 1, ax => ax
        .Raincloud(routes)
        .SetYLabel("Minutes"))
    .Save("routes_raincloud.svg");

A half violin, a box and jittered points for each of two routes, all in one blue, one legend swatch labelled Samples

Highway's box runs 22 to 30 minutes with a median of 25; Backroads spreads wider, 15 to 35 with a median of 22.5 — the shorter route on average, but the less predictable one. Only the violin carries a label ("Samples"), so one legend swatch names the trio rather than standing, falsely, for two differently coloured groups; the routes themselves are named along the X axis. The callback receives RaincloudParts (Violin, Box, Points).

A horizon chart. Horizon folds a signed series into a stack of bands so a long series fits a short strip: each band is a slice of the series' own peak, in a deeper shade of its band's colour.

using MatPlotLibNet;

double[] day = [.. Enumerable.Range(0, 60).Select(i => (double)i)];
double[] anomaly = [.. day.Select(d => 40 * Math.Sin(d / 5.0))];

Plt.Create()
    .WithSize(700, 160)
    .WithTitle("Temperature anomaly")
    .AddSubPlot(1, 1, 1, ax => ax
        .Horizon(day, anomaly, bands: 4)
        .SetXLabel("Day"))
    .Save("anomaly_horizon.svg");

A thin strip of four stacked blue bands for the positive swing and four stacked orange bands for the negative one

The series peaks within a hair of 40, so four bands fold it into steps of 10 each; the non-negative part folds in blue and the negative part in orange, two separate colour groups, so the one signed series reads as two hues instead of one. The callback receives HorizonParts (Positive, Negative), each the bands of its side, lowest first.

1.21.0

Every chart type and every axes setting now comes back from JSON as itself. A treemap's, sunburst's, dendrogram's and clustermap's trees, a Sankey's nodes and links, a polar chart's data, a tree grid's rows, a streaming chart's current points and a planar 3-D bar's data all used to come back as a placeholder or a plain series; the MCP server lists and accepts all eight chart types it had refused by name. An axes' Z axis, its 3-D pane colours and its tooltip switch now travel through JSON too. The one series this release does not reach is MatPlotLibNet.Geo's GeoPolygonSeries, whose projection carries construction parameters of its own that need a shape in the wire format first.

Fixed

A treemap's tree comes back whole. The same holds for a sunburst's and a dendrogram's tree, a clustermap's row and column trees, a Sankey's nodes and links, a polar chart's r and theta, and a tree grid's rows: each now travels through JSON as itself.

using MatPlotLibNet;
using MatPlotLibNet.Models;

var root = new TreeNode
{
    Label = "Root",
    Children =
    [
        new TreeNode
        {
            Label = "Fruit",
            Value = 60,
            Children =
            [
                new TreeNode { Label = "Apples", Value = 35 },
                new TreeNode { Label = "Pears", Value = 25 },
            ],
        },
        new TreeNode { Label = "Vegetables", Value = 40 },
    ],
};

var figure = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax.Treemap(root))
    .Build();

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());
Console.WriteLine(copy.ToSvg() == figure.ToSvg());

A teal Root tile holding a purple Fruit tile split into Apples and Pears, beside a yellow Vegetables tile

It prints True. Before this release the reader ignored the document's tree and built one node labelled "Root", so the copy drew a single blank rectangle, and the MCP server refused the spec by name rather than draw it wrong.

A streaming chart reads back as the same streaming chart. A StreamingLineSeries, StreamingScatterSeries, StreamingCandlestickSeries and StreamingSignalSeries each write a snapshot of their current points under their own name now; the same holds for a planar 3-D bar's data and settings.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series.Streaming;

StreamingLineSeries? series = null;

var figure = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => { series = ax.StreamingPlot(capacity: 500); })
    .Build();

for (var i = 0; i < 20; i++)
{
    series!.AppendPoint(i, Math.Sin(i * 0.3));
}

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());
Console.WriteLine(copy.ToSvg() == figure.ToSvg());

A sine wave from 0 to 20 on the X axis, one full up-and-down swing between -1 and 1

It prints True. Before this release the four streaming kinds shared one wire name, or had none at all, so the copy came back with no streaming behaviour and none of its points.

An axes' Z axis, 3-D pane colours and tooltip switch travel too.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

var figure = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Scatter3D([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0])
        .SetZLabel("Depth")
        .WithPane3D(p => p with { FloorColor = Colors.Black })
        .WithTooltips())
    .Build();

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());
Console.WriteLine(copy.SubPlots[0].ZAxis.Label);
Console.WriteLine(copy.SubPlots[0].EnableTooltips);
Console.WriteLine(copy.ToSvg() == figure.ToSvg());

Three points on 3-D axes with a black floor pane and a Depth label on the Z axis

It prints Depth, True and True. Before this release these were drawn from but never written, so the copy came back with the library's default Z axis label, tooltips switched off and the theme's default pane colours regardless of what the figure set.

1.20.0

Fifteen charts that took a page of hand-written code are one call each on the axes builder now. They are extension methods in the namespace MatPlotLibNet, so the using MatPlotLibNet; every program has brings them in, and they sit in four classes named for the data they take: values keyed by names (CategoricalAxesExtensions), a sample to estimate from (DistributionAxesExtensions), values over x positions (XYAxesExtensions) and the cells of a grid (GridAxesExtensions). Each verb builds its chart from series the library already has, one series for each thing it draws, so a legend click hides one thing. Its last argument, configure, styles those series after the verb has set them up, so what you set there wins. A refused call throws before it adds anything. Written to JSON, a composed chart is its parts, and the MCP server and every transport carry it as such.

Charts from names

A lollipop chart. Lollipop draws one stem for each category, on the centre of the category's slot, with the categories named on the X axis.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

string[] products = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
double[] scores = [82.5, 67.3, 91.1, 54.8, 73.0];

Plt.Create()
    .WithSize(600, 380)
    .AddSubPlot(1, 1, 1, ax => ax
        .Lollipop(products, scores, s => s.StemColor = Colors.SteelBlue)
        .SetYLabel("Score"))
    .Save("scores_lollipop.svg");

Five steel-blue stems with round heads, named Alpha to Epsilon

Five stems stand on the baseline at 0, Gamma the tallest at 91.1, each with a round head and its name under it. The chart is one stem series: one colour, one legend entry. The verb sets no axis limits, and the first and last heads keep the axis margin, so both are whole circles.

A dumbbell chart. Dumbbell puts two dots on each category, one for each BarGroup, and joins them.

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

string[] products = ["A", "B", "C", "D"];

Plt.Create()
    .WithSize(600, 380)
    .AddSubPlot(1, 1, 1, ax => ax
        .Dumbbell(products,
            new BarGroup("2024", [62.0, 78.5, 83.0, 55.0]),
            new BarGroup("2025", [74.0, 82.0, 79.5, 48.0]))
        .SetYLabel("Satisfaction (%)")
        .WithLegend(LegendPosition.LowerLeft))
    .Save("satisfaction_dumbbell.svg");

Pairs of blue and orange dots joined by grey bars, two going up and two going down

The 2024 dots are blue and the 2025 dots orange, named in the legend by their groups. A grey connector joins the two dots of every product, also where the value fell (C from 83.0 to 79.5, D from 55 to 48): it runs from the lower value to the higher. The connector has no legend entry, so a click on "2024" hides the blue dots and leaves the connectors. The callback receives DumbbellParts (Connector, From, To).

A Pareto chart. Pareto sorts the categories by count, draws them as bars and adds the running share of the total as a line on a right-hand axis.

using MatPlotLibNet;

string[] defects = ["Dents", "Scratches", "Stains", "Cracks", "Warp"];
double[] counts = [98, 142, 45, 67, 9];

Plt.Create()
    .WithSize(700, 420)
    .WithTitle("Defects by kind")
    .AddSubPlot(1, 1, 1, ax => ax
        .Pareto(defects, counts, parts => parts.Bars.Label = "Defects")
        .SetXLabel("Kind of defect"))
    .Save("defects_pareto.svg");

Blue bars from Scratches down to Warp and an orange line rising to 100 percent

The bars run from Scratches (142) down to Warp (9). The orange line starts on top of the first bar at 39.3 % and ends at exactly 100 %. Both Y axes are one scale, 0 to 1.05 times the total of 361 on the left and 0 to 105 % on the right, so a count on the left reads its share on the right. The left axis is blue and titled "Defects", the name the callback gave the bars; the right axis is orange and titled "Cumulative %". The legend stands right of centre, between the short bars and the line.

A slope graph. SlopeGraph draws a line for each category between two moments, named by the two groups.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

string[] regions = ["North", "South", "East", "West"];

Plt.Create()
    .WithSize(600, 420)
    .WithTitle("Sales by region")
    .AddSubPlot(1, 1, 1, ax => ax
        .SlopeGraph(regions,
            new BarGroup("2015", [42.0, 55.0, 38.0, 61.0]),
            new BarGroup("2025", [51.0, 49.0, 44.0, 66.0]))
        .SetYLabel("Sales"))
    .Save("regions_slope.svg");

Four lines from 2015 to 2025, South falling, each named at both ends

Each region is named at both ends: right of its end point in the line's colour, and left of its start point in the theme's text colour, so there is no legend. South is the one line that falls. The start names are centred on their markers, at the same size as the end names.

A dot plot. DotPlot draws a Cleveland dot plot: the categories down the Y axis, a dot for each group.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

string[] languages = ["Ruby", "Go", "Rust", "Java", "Python"];

Plt.Create()
    .WithSize(600, 380)
    .WithTitle("Programming languages")
    .AddSubPlot(1, 1, 1, ax => ax
        .DotPlot(languages,
        [
            new BarGroup("2015", [31.0, 48.0, 62.0, 55.0, 80.0]),
            new BarGroup("2025", [35.0, 52.0, 60.0, 58.0, 84.0]),
        ])
        .SetXLabel("Share of developers (%)"))
    .Save("languages_dot_plot.svg");

Python, Rust, Java, Go and Ruby from top to bottom, a blue and an orange dot on each row

The rows are in the order of the first group, largest on top: Python, Rust, Java, Go, Ruby. The guide line through each row is the theme's grid, and the legend stands in the lower right corner, which the order leaves empty.

Parallel coordinates. ParallelCoordinates draws an axis for each dimension and a line for each row; rows that share a name are one class.

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

string[] measures = ["Sepal length", "Sepal width", "Petal length", "Petal width"];

Plt.Create()
    .WithSize(760, 400)
    .WithTitle("Three species of iris")
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax
        .ParallelCoordinates(measures,
        [
            new BarGroup("setosa", [5.1, 3.5, 1.4, 0.2]),
            new BarGroup("setosa", [4.9, 3.0, 1.4, 0.2]),
            new BarGroup("setosa", [4.7, 3.2, 1.3, 0.2]),
            new BarGroup("versicolor", [7.0, 3.2, 4.7, 1.4]),
            new BarGroup("versicolor", [6.4, 3.2, 4.5, 1.5]),
            new BarGroup("versicolor", [5.5, 2.3, 4.0, 1.3]),
            new BarGroup("virginica", [6.3, 3.3, 6.0, 2.5]),
            new BarGroup("virginica", [5.8, 2.7, 5.1, 1.9]),
            new BarGroup("virginica", [7.1, 3.0, 5.9, 2.1]),
        ])
        .WithLegend(LegendPosition.OutsideRight))
    .Save("iris_parallel.svg");

Nine lines across four grey axes, in three colours, the legend beside the plot

Nine rows make three lines of three colours, one legend entry for each species. Each column is scaled to 0..1 by its own smallest and largest value, so the Y axis carries no ticks; the four grey axis lines are the dimensions. The setosa rows run low on the two petal axes, where the other two species stand apart.

A Marimekko chart. Marimekko makes each column as wide as its share of the total and divides it into the segments' shares.

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

string[] channels = ["Retail", "Online", "Wholesale"];

Plt.Create()
    .WithSize(700, 420)
    .WithTitle("Market by channel and brand")
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax
        .Marimekko(channels,
        [
            new BarGroup("Brand A", [30, 15, 5]),
            new BarGroup("Brand B", [20, 25, 10]),
            new BarGroup("Brand C", [10, 20, 15]),
        ])
        .SetXLabel("Channel (width: its share of the market)")
        .WithLegend(LegendPosition.OutsideRight))
    .Save("market_marimekko.svg");

Three columns of 40, 40 and 20 percent width, each split into three coloured cells

Retail and Online hold 60 of the 150 each, so they are 40 % wide, and Wholesale 20 %. Brand A is the lowest cell of every column: 50 % of Retail, 25 % of Online, 16.7 % of Wholesale. The area of a cell is its share of the whole market, and the Y axis reads in percent of the column.

Charts from a sample

A ridgeline. Ridgeline estimates the density of each group and draws it as a ridge on a baseline of its own. A group is a NamedSample: a name and a sample of any length.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

NamedSample[] months =
[
    new("January", [2.1, 3.4, 1.8, 0.9, 2.6, 3.0, 1.2]),
    new("April", [9.8, 12.1, 11.4, 8.7, 10.9, 13.2]),
    new("July", [21.5, 24.0, 19.8, 23.1, 22.7, 25.3, 20.4, 22.0]),
];

Plt.Create()
    .WithSize(600, 400)
    .WithTitle("Daily highs")
    .AddSubPlot(1, 1, 1, ax => ax
        .Ridgeline(months, configure: ridge => ridge.Alpha = 0.6)
        .SetXLabel("Temperature (°C)"))
    .Save("highs_ridgeline.svg");

Three ridges, January on top near 2 degrees, July at the bottom near 22

January is on top and July at the bottom, the group names on the Y axis. Every ridge is estimated on one grid with one bandwidth, the mean of the groups' own Silverman bandwidths (ggridges' rule), so the ridges compare; pass bandwidth to set the smoothing yourself. The tallest ridge reaches the next baseline.

A Q-Q plot. QQPlot plots a sample against the quantiles of the normal distribution, with scipy's conventions (scipy.stats.probplot).

using MatPlotLibNet;

double[] returns = [0.8, -1.2, 0.3, 2.1, -0.4, 0.9, -2.6, 0.1, 1.4, -0.7, 0.5, -0.2];

Plt.Create()
    .WithSize(600, 420)
    .WithTitle("Daily returns against the normal distribution")
    .AddSubPlot(1, 1, 1, ax => ax
        .QQPlot(returns)
        .SetYLabel("Daily return (%)"))
    .Save("returns_qq.svg");

Twelve blue points along a red reference line

The twelve returns stand along the red least-squares line, the lowest one, -2.6, below it. The X axis is titled "Theoretical quantiles"; the Y title set after the call replaces the verb's "Sample quantiles". The callback receives QQPlotParts (Points, Reference).

A Kaplan-Meier curve. KaplanMeier draws the survival curve of each group. A SurvivalGroup holds each subject's follow-up time and whether its event was seen there (true) or the subject was censored (false).

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

SurvivalGroup[] arms =
[
    new("Treatment", [6, 7, 10, 15, 19, 25, 32, 34], [true, true, false, true, false, true, false, false]),
    new("Placebo", [1, 2, 3, 4, 5, 8, 8, 11], [true, true, true, false, true, true, true, true]),
];

Plt.Create()
    .WithSize(600, 400)
    .WithTitle("Time to relapse")
    .AddSubPlot(1, 1, 1, ax => ax
        .KaplanMeier(arms)
        .SetXLabel("Weeks"))
    .Save("relapse_kaplan_meier.svg");

Two step curves from 1, the treatment held at 0.4, the placebo down to 0 at week 11

Both curves start at 1 and step down at each event. The placebo curve drops by two thirds at week 8, where two events fall among three subjects at risk, and reaches 0 at week 11; the treatment curve holds at 0.4 from week 25 to its last follow-up at week 34. The Y axis is titled "Survival probability". The verb draws the curve only: no confidence band, censor marks or risk table.

Charts over x

A bump chart. BumpChart draws each group's rank over time, rank 1 on top, each group named at the end of its line.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Rendering.TickLocators;

double[] seasons = [2021, 2022, 2023, 2024];

Plt.Create()
    .WithSize(600, 380)
    .WithTitle("League table")
    .AddSubPlot(1, 1, 1, ax => ax
        .BumpChart(seasons,
        [
            new BarGroup("Hawks", [1, 2, 3, 3]),
            new BarGroup("Owls", [2, 1, 1, 2]),
            new BarGroup("Wrens", [3, 3, 2, 1]),
        ])
        .SetXTickLocator(new MultipleLocator(1))
        .SetXLabel("Season")
        .SetYLabel("Rank"))
    .Save("league_bump.svg");

Three crossing lines, the Wrens climbing from 3 to 1, named at their right ends

The Wrens climb from third to first, the Hawks fall from first to third. The Y axis is ticked at the whole ranks 1, 2 and 3, with half a rank of room at each end so the markers on the first and last rank are whole. The names stand at the line ends in the lines' colours, so there is no legend. MultipleLocator(1) gives the seasons whole-number ticks.

A control chart. ControlChart draws an individuals chart: the values, the centre line and the two control limits, with every value beyond a limit marked.

using MatPlotLibNet;

double[] day = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
double[] fill = [50.2, 49.8, 50.1, 50.4, 49.9, 50.0, 53.9, 50.3, 49.7, 50.1];

Plt.Create()
    .WithSize(650, 400)
    .WithTitle("Fill weight")
    .AddSubPlot(1, 1, 1, ax => ax
        .ControlChart(day, fill)
        .SetXLabel("Day")
        .SetYLabel("Weight (g)"))
    .Save("fill_control_chart.svg");

A blue line with a centre line and two dashed limits; day 7 is an orange dot above the upper limit

The limits are estimated from the values: the centre is the mean, 50.44, and sigma is the average moving range over 1.128, which puts the limits at 47.46 and 53.42. Day 7, at 53.9, is beyond the upper limit and drawn as an orange dot. new ControlLimits(center, lower, upper) as the third argument draws limits you already have; the callback receives ControlChartParts.

A stream graph. StreamGraph stacks its layers around the middle of the plot instead of on zero.

using MatPlotLibNet;
using MatPlotLibNet.Models;
using MatPlotLibNet.Models.Series;
using MatPlotLibNet.Rendering.TickLocators;

double[] year = [2019, 2020, 2021, 2022, 2023];

Plt.Create()
    .WithSize(700, 400)
    .WithTitle("Listening by genre")
    .TightLayout()
    .AddSubPlot(1, 1, 1, ax => ax
        .StreamGraph(year,
        [
            new BarGroup("Rock", [8, 7, 6, 5, 4]),
            new BarGroup("Pop", [3, 5, 7, 8, 9]),
            new BarGroup("Jazz", [2, 2, 3, 3, 4]),
        ])
        .SetXTickLocator(new MultipleLocator(1))
        .SetXLabel("Year")
        .WithLegend(LegendPosition.OutsideRight))
    .Save("genres_stream_graph.svg");

Three opaque streams, Rock thinning and Pop swelling, the legend beside the plot

Rock, the first layer, is the lowest stream and thins from 8 to 4 while Pop swells from 3 to 9. A layer's value is the thickness of its stream, so the Y axis has no ticks. The baseline is matplotlib's wiggle; StackedBaseline as the third argument picks WeightedWiggle, Symmetric or Zero.

Charts from a grid

A calendar heatmap. CalendarHeatmap draws one cell for each day, in seven weekday rows by one column for each week.

using MatPlotLibNet;
using MatPlotLibNet.Styling.ColorMaps;

var first = new DateTime(2025, 1, 1);
DateTime[] days = [.. Enumerable.Range(0, 365).Select(d => first.AddDays(d))];
double[] commits = [.. days.Select(d => (double)(d.DayOfYear * 37 % 11))];

Plt.Create()
    .WithSize(1100, 300)
    .WithTitle("Commits in 2025")
    .AddSubPlot(1, 1, 1, ax => ax
        .CalendarHeatmap(days, commits, configure: s => s.ColorMap = ColorMaps.Viridis))
    .Save("commits_calendar.svg");

A year of coloured day cells, weekdays named on the left, months along the bottom, a colour bar on the right

The year is 53 week columns, Monday on top. 2025 starts and ends on a Wednesday, so the first column is blank on Monday and Tuesday and the last from Thursday to Sunday: 6 of its 371 cells. A blank day has no colour, while a day with 0 commits wears the bottom of the colour map. Each month is named under the column that holds its 1st. The week starts on the day the call names (Monday unless you pass another), never on the machine's culture, so the picture is the same everywhere.

A spy plot. Spy marks every cell of a matrix whose magnitude is above a threshold, 0 unless you give one.

using MatPlotLibNet;

// A banded matrix: the diagonal and the two diagonals beside it.
var band = new double[20, 20];
for (int r = 0; r < 20; r++)
{
    for (int c = Math.Max(0, r - 1); c <= Math.Min(19, r + 1); c++)
    {
        band[r, c] = 1;
    }
}

Plt.Create()
    .WithSize(500, 500)
    .WithTitle("A tridiagonal matrix")
    .AddSubPlot(1, 1, 1, ax => ax.Spy(band, configure: s => s.MarkerSize = 12))
    .Save("band_spy.svg");

58 square markers along the diagonal of a 20 by 20 grid, row 0 at the top

The 58 cells of the band are square markers, row 0 at the top as a matrix is written. The axes run half a cell past the matrix and are ticked in whole cells, every 5 for 20 columns. A marker is a size in points, not in cells, so a larger matrix needs a smaller MarkerSize.

Fixed

A figure comes back from JSON as the picture it was. ToJson and FromJson, the MCP server and every transport now carry every setting a figure draws: the theme, a colour map built in code, tick locators and formatters, the legend, the colour bar, and NaN.

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

double[,] load = { { 0.2, 0.5, double.NaN }, { 0.7, 0.9, 0.4 } };
var map = new LinearColorMap("white-to-teal", [Colors.White, Colors.Teal]) { BadColor = Colors.LightGray };

var figure = Plt.Create()
    .WithTheme(Theme.Ggplot)
    .AddSubPlot(1, 1, 1, ax => ax
        .Heatmap(load, s => s.ColorMap = map)
        .WithColorBar())
    .Build();

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());
Console.WriteLine(copy.ToSvg() == figure.ToSvg());

It prints True: the copy is drawn under ggplot, with the colour map's grey in the missing cell. Before this release ToJson threw on the NaN. Without it, the copy came back on the default theme, and the colour map, written by a name nobody had registered, came back as none.

A NaN breaks a line. A missing value leaves a gap, as in matplotlib, where the line used to be drawn straight across it.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] hour = [0, 1, 2, 3, 4, 5, 6, 7];
double[] reading = [12.1, 12.4, double.NaN, double.NaN, 13.0, 13.4, 13.1, 12.8];

Plt.Create()
    .WithSize(600, 350)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, reading, s => s.Marker = MarkerStyle.Circle)
        .SetXLabel("Hour")
        .SetYLabel("Reading"))
    .Save("sensor_gap.svg");

A line in two pieces, with nothing drawn at hours 2 and 3

The line is drawn in two pieces, hours 0 to 1 and 4 to 7, and nothing stands at hours 2 and 3. The Y axis spans the readings that are there. An infinity, and a value a log axis cannot place, break the line the same way.

The resolution reaches the file. WithDpi and SaveOptions were kept and read by no export. A figure is laid out in pixels at 96 dpi, and a PNG is now drawn with dpi / 96 pixels for each of them.

using MatPlotLibNet;

double[] quarter = [1, 2, 3, 4];
double[] revenue = [12, 15, 14, 18];

var figure = Plt.Create()
    .WithSize(500, 350)
    .WithDpi(192)
    .Plot(quarter, revenue)
    .Build();

figure.Save("revenue.png");
figure.Save("revenue_print.png", new SaveOptions { Dpi = 300, Title = "Revenue", Author = "Finance" });
figure.Save("revenue.svg", new SaveOptions { PrettifySvg = true, SvgDecimalPrecision = 2 });

With MatPlotLibNet.Skia installed, revenue.png is 1000 × 700 pixels with the layout of the 500 × 350 figure, drawn finer; revenue_print.png is 1563 × 1094 at 300 dpi and carries the title and the author as text in the file. The SVG is indented one element to a line, with its positions rounded to two decimals. An SVG, a PDF page and JSON have no pixels, so the resolution changes nothing there.

Horizontal bars lie along the values. A horizontal bar chart drew its bars thousands of pixels long and a few pixels thick, with the names under the X axis.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

string[] teams = ["Alpha", "Bravo", "Charlie", "Delta"];
double[] closed = [12500, 8200, 15100, 6400];

Plt.Create()
    .WithSize(600, 320)
    .AddSubPlot(1, 1, 1, ax => ax
        .Bar(teams, closed, s => s.Orientation = BarOrientation.Horizontal)
        .WithBarLabels()
        .SetXLim(0, 18000)
        .SetXLabel("Tickets closed"))
    .Save("tickets_horizontal.svg");

Four horizontal bars named Alpha to Delta on the Y axis, each with its value at its end

The values run along X and the names stand on the Y axis, Alpha at the bottom as matplotlib's barh puts the first; SetYLim(4, 0) puts it on top. The left margin holds the longest name. A value label shows up to six significant digits, so 12500 reads 12500 where it read 1.25E+04.

Bars on a log axis reach the bottom edge. A log axis has no place for 0, so a bar standing on it was not drawn at all.

using MatPlotLibNet;
using MatPlotLibNet.Models;

string[] payload = ["1 MB", "10 MB", "100 MB", "1 GB"];
double[] milliseconds = [15, 120, 1300, 9800];

Plt.Create()
    .WithSize(500, 350)
    .AddSubPlot(1, 1, 1, ax => ax
        .Bar(payload, milliseconds)
        .SetYScale(AxisScale.Log)
        .SetYLabel("Upload time (ms)"))
    .Save("upload_log_bars.svg");

Four bars rising from the bottom of a log axis

Each bar runs from the bottom edge, where the axis begins, up to its value, as matplotlib's log scale clips it. The axis starts a margin below the shortest bar, as matplotlib's does, so the 15 ms of the first one shows. A histogram, a filled area, a box and a violin on a log axis follow the same rule.

A waterfall marks its totals and names its steps. TotalIndices names the steps that are totals; TotalColor was a setting nothing read.

using MatPlotLibNet;

string[] steps = ["Opening", "Sales", "Refunds", "Costs", "Closing"];
double[] amounts = [500, 320, -40, -180, 600];

Plt.Create()
    .WithSize(600, 380)
    .AddSubPlot(1, 1, 1, ax => ax
        .Waterfall(steps, amounts, s => s.TotalIndices = [0, 4])
        .SetYLabel("Cash (k€)"))
    .Save("cash_waterfall.svg");

Two blue total bars standing on zero, with green and red steps between them

Opening and Closing stand on zero in the total colour, at the levels they hold, 500 and 600; Sales rises in green, Refunds and Costs fall in red. A total is stated, not summed: the step after it starts from its level. The steps are named under their bars, where the axis used to number them.

A threshold above the data shows its line and its zone. A line drawn across the plot now widens the automatic range, as matplotlib's axhline does; a threshold's zone is cut at the plot edge.

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

double[] hour = [0, 1, 2, 3, 4, 5, 6, 7];
double[] load = [42, 55, 61, 68, 64, 59, 52, 47];

Plt.Create()
    .WithSize(600, 380)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, load, s => s.Label = "CPU load %")
        .Threshold(80.0, Orientation.Horizontal, ThresholdBreach.Above, color: Colors.Red, label: "Alarm")
        .WithLegend(LegendPosition.LowerRight)
        .SetXLabel("Hour")
        .SetYLabel("Load %"))
    .Save("load_threshold.svg");

A load line below a dashed red line at 80, with a red band above it

The load peaks at 68, yet the Y axis reaches past 80, so the dashed alarm line is drawn and the band above it is shaded up to the top of the plot. Before, the line stood off the plot and the band was never drawn.

Violins and boxes stand where Positions puts them. Their automatic range ignored Positions and Widths, so violins at 10, 20 and 30 were drawn hundreds of pixels past the right edge.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[][] waits =
[
    [4, 5, 5, 6, 7, 7, 8, 9, 12],
    [2, 3, 3, 4, 4, 5, 5, 6, 8],
    [6, 7, 9, 9, 10, 11, 12, 14, 15],
];

Plt.Create()
    .WithSize(600, 380)
    .AddSubPlot(1, 1, 1, ax => ax
        .Violin(waits, s => { s.Positions = [10, 20, 30]; s.Widths = 6; s.Side = ViolinSide.High; })
        .SetXLabel("Floor")
        .SetYLabel("Wait (min)"))
    .Save("waits_violins.svg");

The X axis runs from 5 to 35 and holds all three half violins, each drawn on the right of its position. A box plot with Positions follows the same rule, lying down too.

Also fixed, with nothing new to call:

  • Category names on the X axis are tick labels: hidden ticks hide them, a label size, colour or rotation applies to them, and a theme's grid draws one line through each category.
  • The X axis title has room in both layouts, and tick labels turned by a rotation, or by the 30 degrees crowded labels turn, are measured turned, so the title no longer crosses them; since 1.24.0 they have room beside the plot as well.
  • A theme's property cycler draws its line styles, markers and widths, not only its colours; see Styling#propcycler.
  • A heatmap's cells follow SetXLim, SetYLim and a zoom, under names that stay over the right cells.
  • A scatter draws its Alpha, the contour levels named in LevelValues are the levels drawn, and a pie, a donut, a funnel and a stacked area take the figure's theme.

1.19.0

New

Name a line where it ends. WithDirectLabel() writes the name of the line added just before it at the right end of that line, in the line's colour, and leaves it out of the legend. A reader no longer has to match a swatch to a line.

using MatPlotLibNet;
using MatPlotLibNet.Models;

double[] month = [1, 2, 3, 4, 5, 6, 7, 8];
double[] oslo = [-4.3, -4.0, -0.2, 4.5, 10.8, 15.2, 16.4, 15.2];
double[] madrid = [6.3, 7.9, 11.2, 12.9, 16.7, 22.2, 25.6, 25.1];
double[] rome = [7.5, 8.5, 10.9, 13.6, 17.6, 21.6, 24.4, 24.5];
double[] average = [3.2, 4.1, 7.3, 10.3, 15.0, 19.7, 22.1, 21.6];

Plt.Create()
    .WithSize(700, 400)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(month, oslo, s => s.Label = "Oslo").WithDirectLabel()
        .Plot(month, madrid, s => s.Label = "Madrid").WithDirectLabel()
        .Plot(month, rome, s => s.Label = "Rome").WithDirectLabel()
        .Plot(month, average, s => s.Label = "Average of the three")   // no WithDirectLabel: stays in the legend
        .WithLegend(LegendPosition.UpperLeft)
        .SetXLabel("Month")
        .SetYLabel("Mean temperature (°C)"))
    .Save("direct_labels.svg");

Three lines named at their ends, one in the legend

Oslo, Madrid and Rome stand in a column right of the plot, each in its line's colour, at the height where its line ends. Madrid and Rome end 0.6 °C apart, yet their names stand one above the other without touching, in the order the lines end. The legend holds a single entry: Average of the three. The figure keeps its size; the plot narrows to make room for the names.

Show the last value beside the name, or set the placement on the series. WithDirectLabel() is a shorthand for LabelPlacement = SeriesLabelPlacement.LineEnd. With WithLegendValues() the label carries the value it stands next to, as the legend entry would.

using MatPlotLibNet;
using MatPlotLibNet.Models.Series;

double[] day = [1, 2, 3, 4, 5];
double[] visitors = [120, 135, 128, 150, 162];
double[] orders = [31, 38, 35, 44, 47];

Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(day, visitors, s => { s.Label = "Visitors"; s.LabelPlacement = SeriesLabelPlacement.LineEnd; })
        .Plot(day, orders, s => { s.Label = "Orders"; s.LabelPlacement = SeriesLabelPlacement.LineEnd; })
        .WithLegendValues())
    .Save("last_values.svg");

The two labels read Visitors = 162.00 and Orders = 47.00, with a decimal point whatever the culture of the machine. A live line from StreamingPlot takes the same setting. Polar and 3-D axes draw no end labels, so there a line keeps its legend entry.

Colour an axis to match its line. SetYAxisColor and SetXAxisColor colour an axis' spine, tick marks, tick labels and title in one call. They exist on the axes builder (left and bottom axis), on the builder of every Y axis added with WithAddRightYAxis or WithAddLeftYAxis, and on the top axis' builder. A line on the right-hand axis is named at its end as well.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] hour = [0, 4, 8, 12, 16, 20, 24];
double[] temperature = [12, 10, 14, 21, 23, 17, 13];
double[] humidity = [88, 92, 75, 52, 47, 63, 81];

var figure = Plt.Create()
    .WithSize(700, 400)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, temperature, s => { s.Label = "Temperature"; s.Color = Colors.Crimson; }).WithDirectLabel()
        .SetYLabel("Temperature (°C)")
        .SetYAxisColor(Colors.Crimson)
        .SetXLabel("Hour of the day")
        .WithAddRightYAxis(right => right
            .Plot(hour, humidity, s => { s.Label = "Humidity"; s.Color = Colors.SteelBlue; })
            .WithDirectLabel()
            .SetYLabel("Relative humidity (%)")
            .SetYAxisColor(Colors.SteelBlue)))
    .TightLayout()
    .Build();

figure.Save("axis_colors.svg");

Left axis in crimson, right axis in steel blue, both lines named at their ends

The left spine, its ticks, its numbers and its title are crimson; the right ones are steel blue. Both names stand right of the plot, past the right-hand numbers and title, each at the height where its own line ends. Everything else on the axes (tick length, font size, spine width) stays as it was. Keep an axis title that names the series and its unit, so the colour is never the only thing that tells a reader which axis a line belongs to.

Keep it through JSON. REST, SignalR, Blazor and the MCP server hand a figure on as JSON. The axis colours and the label placement now travel with it; JSON written by an earlier version reads as no colour and a legend entry.

using MatPlotLibNet.Models.Series;

var copy = ChartSerializerDefault.Current.FromJson(figure.ToJson());
var axes = copy.SubPlots[0];

Console.WriteLine(axes.Spines.Right.Color?.ToHex());                            // #4682B4
Console.WriteLine(axes.YAxes.Right[0].Axis.MajorTicks.LabelColor?.ToHex());     // #4682B4
Console.WriteLine(((LineSeries)axes.YAxes.Right[0].Series[0]).LabelPlacement);  // LineEnd

It prints #4682B4 twice and LineEnd. A top axis made with WithSecondaryXAxis, with its label, limits and series, survives the trip as well; before this release it was lost on the way.

Colour the top axis, and the title makes room for it. A second X axis along the top reads the same measurements in another unit. Its numbers and title now get room of their own, and the axes title stands above them.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] celsius = [-10, 0, 10, 20, 30, 40];
double[] fahrenheit = [14, 32, 50, 68, 86, 104];
double[] efficiency = [71, 80, 88, 93, 90, 82];

Plt.Create()
    .WithSize(700, 450)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(celsius, efficiency, s => s.Color = Colors.Teal)
        .SetXLabel("Outdoor temperature (°C)")
        .SetXAxisColor(Colors.Teal)
        .SetYLabel("Efficiency (%)")
        .WithTitle("Heat pump efficiency")
        .WithSecondaryXAxis(top => top
            .ScatterXSecondary(fahrenheit, efficiency, s => s.Color = Colors.Chocolate)
            .SetXLabel("Outdoor temperature (°F)")
            .SetXAxisColor(Colors.Chocolate)))
    .Save("top_axis.svg");

A teal bottom axis in °C, a chocolate top axis in °F, the title above both

The teal line is read in °C on the bottom axis; the chocolate dots, the same measurements read in °F on the top axis, sit on the line. The title stands above the top numbers and the top axis title, and nothing overlaps.

Improved

A secondary axis takes its tick settings. The ticks of the right-hand and the top axis follow that axis' own MajorTicks: direction, length, width, colour, label size, label colour, padding, rotation and Visible = false. Before this release every one of those settings was ignored there.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] hour = [0, 4, 8, 12, 16, 20, 24];
double[] load = [310, 280, 420, 560, 610, 480, 330];
double[] price = [0.21, 0.18, 0.27, 0.34, 0.38, 0.30, 0.22];

var figure = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, load)
        .SetYLabel("Load (MW)")
        .WithAddRightYAxis(right => right
            .Plot(hour, price, s => s.Color = Colors.Tab10Orange)
            .SetYLabel("Price (EUR/kWh)")))
    .Build();

var right = figure.SubPlots[0].YAxes.Right[0].Axis;
right.MajorTicks = right.MajorTicks with { Direction = TickDirection.In, Length = 8, LabelSize = 12, LabelColor = Colors.Navy };

figure.Save("right_ticks.svg");

Right-hand ticks pointing into the plot, with larger navy numbers

The right-hand marks point into the plot and are 8 px long, and their numbers are larger and navy. Left untouched, a secondary axis now looks like the primary one: marks 4.86 px long and 1.11 px wide, numbers 9.72 px from the spine.

Mirrored ticks are drawn, and the layout makes room for them. WithXTicksMirrored() repeats the X ticks along the top edge, where before it set a flag nothing read. Mirrored Y ticks point away from the plot on the right edge, as they do on the left.

using MatPlotLibNet;

double[] depth = [0, 50, 100, 150, 200, 250];
double[] pressure = [101325, 605000, 1110000, 1615000, 2120000, 2625000];

Plt.Create()
    .WithSize(600, 400)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(depth, pressure)
        .WithTitle("Pressure under water")
        .SetXLabel("Depth (m)")
        .SetYLabel("Pressure (Pa)")
        .WithXTicksMirrored()
        .WithYTicksMirrored())
    .TightLayout()
    .Save("mirrored_ticks.svg");

Ticks and numbers on all four sides, the title above the top ones

The depth numbers appear along the top as well as the bottom, and the title stands above them instead of on them. The pressure numbers appear on both sides, and the right margin is as wide as the widest of them. A secondary axis with series takes its edge for its own ticks, so the mirrored ticks are not drawn on that edge.

A legend click hides a line on a secondary axis. A line on the right-hand or the top axis is drawn in a series group of its own, like a line on the main axes. The legend toggle, the hover highlight and a streaming update all find it there.

using MatPlotLibNet;

double[] hour = [0, 4, 8, 12, 16, 20, 24];
double[] temperature = [12, 10, 14, 21, 23, 17, 13];
double[] humidity = [88, 92, 75, 52, 47, 63, 81];

Plt.Create()
    .WithBrowserInteraction()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, temperature, s => s.Label = "Temperature")
        .SetYLabel("Temperature (°C)")
        .WithAddRightYAxis(right => right
            .Plot(hour, humidity, s => s.Label = "Humidity")
            .SetYLabel("Relative humidity (%)"))
        .WithLegend())
    .Save("legend_toggle.svg");

Open legend_toggle.svg in a browser and click Humidity in the legend: the right-hand line disappears, and a second click brings it back. Before this release the click hid nothing. A click on a plotted line no longer toggles it either; only a click on a legend entry does.

A theme's colour cycle reaches a secondary line. The colour a line is drawn in and the colour of its legend swatch are decided in one place, for the main axes and the secondary ones alike.

using MatPlotLibNet;
using MatPlotLibNet.Styling;

double[] x = [1, 2, 3, 4, 5];
double[] sales = [12, 15, 14, 18, 21];
double[] margin = [0.31, 0.29, 0.33, 0.35, 0.34];

var theme = Theme.CreateFrom(Theme.Default)
    .WithPropCycler(new PropCyclerBuilder().WithColors(Colors.Red, Colors.Green, Colors.Blue).Build())
    .Build();

Plt.Create()
    .WithTheme(theme)
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(x, sales, s => s.Label = "Sales")
        .WithAddRightYAxis(right => right.Plot(x, margin, s => s.Label = "Margin"))
        .WithLegend())
    .Save("cycled_secondary.svg");

Sales is drawn red and Margin green (#008000), and the legend swatches show the same two colours. Before, the right-hand line took the plain colour cycle (#FF7F0E) while its swatch showed green. Tooltips (WithTooltips()) and theme settings reach series on a secondary axis too.

A click on a secondary line reports its own values. With the server data cursor, a click on a line on the right-hand axis reports Y in right-hand units, a line on the top axis reports X in top-axis units, and every click is measured against the plot area and the range the axes actually show. Hover, Shift-drag selection, wheel zoom and drag pan work in the units of the axes under the pointer too.

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

double[] hour = [0, 4, 8, 12, 16, 20, 24];
double[] temperature = [12, 10, 14, 21, 23, 17, 13];
double[] humidity = [88, 92, 75, 52, 47, 63, 81];

var figure = Plt.Create()
    .AddSubPlot(1, 1, 1, ax => ax
        .Plot(hour, temperature, s => s.Label = "Temperature")
        .WithAddRightYAxis(right => right.Plot(hour, humidity, s => s.Label = "Humidity")))
    .WithServerInteraction("weather", i => i.EnableDataCursor())
    .Build();

app.Services.GetRequiredService<FigureRegistry>().Register("weather", figure, options => options
    .OnDataCursor(evt =>
    {
        Console.WriteLine($"{evt.Annotation.SeriesLabel}: {evt.Annotation.DataX:0.#} h, {evt.Annotation.DataY:0.#}");
        return ValueTask.CompletedTask;
    }));

app.Run();

A click on the humidity line at noon prints a value near Humidity: 12 h, 52, read on the right-hand axis in percent; a click on the temperature line reads the left axis in °C. Before this release every click was converted with the left axis' limits over the whole picture, so an autoscaled chart reported numbers that matched neither axis. See Bidirectional SignalR for the page this figure is shown on.

Clone this wiki locally