Skip to content

Examples

H.P. Gansevoort edited this page Oct 29, 2026 · 13 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.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.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