Cartesian charts

edit

Bar chart

A BarMark<T> draws a bar per row, from zero to its value, in the band of its category.

Sale[] sales =
[
    new("North", 2024, 150),
    new("South", 2024, 95),
    new("East", 2024, 105),
    new("West", 2024, 160),
];

var plot = new Plot { Title = "Sales by region", Subtitle = "2024, in thousands of euros" };
plot.Add(new BarMark<Sale>(sales, x: sale => sale.Region, y: sale => sale.Amount));
var svg = plot.ToSvg();

A bar chart of the sales of four regions

Grouped bar chart

Group divides each band into side-by-side bars. A numeric channel that groups or stacks bars, such as a year, is used as a category.

var plot = new Plot { Title = "Sales by region and year" };
plot.Add(new BarMark<Sale>(SampleData.Sales, x: sale => sale.Region, y: sale => sale.Amount)
{
    Group = sale => sale.Year,
    Fill = sale => sale.Year,
});

Bars of three years side by side for each region

Stacked bar chart

A StackTransform stacks the bars of a category. By default, positive values go up from zero and negative values go down.

var plot = new Plot { Title = "Sales by region, stacked by year" };
plot.Add(new BarMark<Sale>(SampleData.Sales, x: sale => sale.Region, y: sale => sale.Amount)
{
    Fill = sale => sale.Year,
    Transform = new StackTransform(),
});

The bars of three years stacked for each region

100% stacked bar chart

StackOffset.Expand normalizes each stack from 0 to 1.

var plot = new Plot
{
    Title = "Share of each year",
    YScale = new LinearScale { TickFormat = NumberFormats.Percent() },
};
plot.Add(new BarMark<Sale>(SampleData.Sales, x: sale => sale.Region, y: sale => sale.Amount)
{
    Fill = sale => sale.Year,
    Transform = new StackTransform { Offset = StackOffset.Expand },
});

Stacked bars that each go from 0% to 100%

Horizontal bar chart

Transposed coordinates draw the x scale vertically: the bars are horizontal. The categories read from the top down.

var plot = new Plot
{
    Title = "Sales by region",
    Coordinates = new CartesianCoordinates { Transposed = true },
    Height = 260,
};
plot.Add(new BarMark<Sale>(SampleData.Sales.Where(sale => sale.Year == 2024), x: sale => sale.Region, y: sale => sale.Amount)
{
    CornerRadius = 3,
});
plot.YAxis.Label = "Amount";

Horizontal bars, one per region

Line chart

A LineMark<T> joins the rows in their order. Times use a time scale, whose labels show the largest unit each tick does not fall on: the year, then the months.

var paris = SampleData.Readings.Where(reading => reading.City == "Paris");
var plot = new Plot { Title = "Monthly temperature in Paris" };
plot.Add(new LineMark<Reading>(paris, x: reading => reading.Date, y: reading => reading.Temperature) { MarkerRadius = 3 });
plot.YAxis.Label = "°C";

The monthly temperature of Paris as a line with dots

Multi-series line chart

The Stroke channel draws a line per value, in its color. Curve.MonotoneX smooths the lines without overshooting the data.

var plot = new Plot { Title = "Monthly temperature" };
plot.Add(new RuleYMark(0));
plot.Add(new LineMark<Reading>(SampleData.Readings, x: reading => reading.Date, y: reading => reading.Temperature)
{
    Stroke = reading => reading.City,
    Curve = Curve.MonotoneX,
});

Three smooth lines, one per city, and a line at zero

Step line

Curve.Step, Curve.StepBefore and Curve.StepAfter join the points with horizontal steps.

Three step lines

Area chart

An AreaMark<T> fills the area between a line and zero, or another baseline (Y1).

var oslo = SampleData.Readings.Where(reading => reading.City == "Madrid");
var plot = new Plot { Title = "Monthly temperature in Madrid" };
plot.Add(new AreaMark<Reading>(oslo, x: reading => reading.Date, y: reading => reading.Temperature)
{
    FillOpacity = 0.3,
    StrokeColor = "#4269d0",
    Curve = Curve.MonotoneX,
});

The monthly temperature of Madrid as a filled area

Stacked area chart

A stack transform stacks the areas of the series.

var plot = new Plot { Title = "Sales by year, stacked by region" };
plot.Add(new AreaMark<Sale>(SampleData.Sales, x: sale => sale.Year, y: sale => sale.Amount)
{
    Fill = sale => sale.Region,
    Transform = new StackTransform(),
});
plot.XScale = new LinearScale { Ticks = [2022, 2023, 2024], TickFormat = year => year.ToString("0", CultureInfo.InvariantCulture) };

The sales of four regions stacked as areas over three years

Streamgraph

StackOffset.Silhouette centers the stacks on zero, and StackOrder.InsideOut puts the series that peak first in the middle.

var plot = new Plot { Title = "Streamgraph" };
plot.Add(new AreaMark<Reading>(SampleData.Readings, x: reading => reading.Date, y: reading => reading.Temperature + 15)
{
    Fill = reading => reading.City,
    Curve = Curve.MonotoneX,
    Transform = new StackTransform { Offset = StackOffset.Silhouette, Order = StackOrder.InsideOut },
});
plot.YAxis.Visible = false;

Three streams centered on a horizontal axis

Scatter plot

A DotMark<T> draws a dot per row. The fill channel colors the dots and adds a legend; the title channel adds a tooltip.

var plot = new Plot { Title = "Fuel efficiency by weight" };
plot.Add(new DotMark<Car>(SampleData.Cars, x: car => car.Weight, y: car => car.Mpg)
{
    Fill = car => car.Origin,
    Title = car => car.Name,
});
plot.XAxis.Label = "Weight (kg)";
plot.YAxis.Label = "Miles per gallon";
plot.XAxis.Grid = true;

Dots of cars by weight and fuel efficiency, colored by origin

Bubble chart

The Size channel goes through the size scale: the area of a dot is proportional to its value. A legend of nested circles explains the sizes, next to the legend of the colors.

var plot = new Plot { Title = "Power, weight and efficiency" };
plot.Add(new DotMark<Car>(SampleData.Cars, x: car => car.Weight, y: car => car.Mpg)
{
    Size = car => car.Horsepower,
    Fill = car => car.Origin,
    FillOpacity = 0.7,
    StrokeColor = ChartColor.White,
});
plot.SizeScale = new SizeScale { MaxRadius = 20 };

Bubbles whose areas are proportional to the power of the cars

Size legend

Plot.SizeLegend sets the title of the legend of the sizes, its values (up to three round values of the domain by default) and its visibility. It follows the Position of the legend of the colors: on the right, the two legends are stacked; above or under the chart, they are side by side.

var plot = new Plot { Title = "Power, weight and efficiency" };
plot.Add(new DotMark<Car>(SampleData.Cars, x: car => car.Weight, y: car => car.Mpg)
{
    Size = car => car.Horsepower,
    Fill = car => car.Origin,
    FillOpacity = 0.7,
    StrokeColor = ChartColor.White,
});
plot.SizeScale = new SizeScale { MaxRadius = 20 };
plot.Legend.Position = LegendPosition.Right;
plot.Legend.Title = "Origin";
plot.SizeLegend.Title = "Horsepower";
plot.SizeLegend.Values = [100, 150, 250];

A bubble chart with the legends of the origins and of the horsepower on its right

Annotations

An AnnotationMark<T> draws a text at an offset of DX and DY pixels from a point, and an arrow from the text to the point. The text is anchored on the side of the offset, a line break starts a new line, and Bend curves the arrow.

var plot = new Plot { Title = "Monthly temperatures" };
plot.Add(new LineMark<Reading>(SampleData.Readings, x: reading => reading.Date, y: reading => reading.Temperature) { Stroke = reading => reading.City });
plot.Add(new AnnotationMark<Reading>(
    SampleData.Readings.Where(reading => reading.City == "Madrid").MaxBy(reading => reading.Temperature) is { } hottest ? [hottest] : [],
    x: reading => reading.Date,
    y: reading => reading.Temperature,
    text: reading => "Hottest month\n" + NumberFormats.Default(reading.Temperature) + " °C in Madrid")
{
    DX = 60,
    DY = -5,
});
plot.Add(new AnnotationMark<Reading>(
    SampleData.Readings.Where(reading => reading.City == "Oslo").MinBy(reading => reading.Temperature) is { } coldest ? [coldest] : [],
    x: reading => reading.Date,
    y: reading => reading.Temperature,
    text: reading => "Coldest: " + NumberFormats.Default(reading.Temperature) + " °C in Oslo")
{
    DX = 50,
    DY = -40,
    Bend = -40,
});

Temperature lines with arrows pointing at the hottest and the coldest months

Secondary axis

Marks that set UseSecondaryYScale use the secondary y scale (Plot.SecondaryYScale), whose axis is on the right: two measures of different units in one chart.

Share[] revenue = [new("Q1", 120), new("Q2", 150), new("Q3", 140), new("Q4", 190)];
Share[] growth = [new("Q1", 0.04), new("Q2", 0.25), new("Q3", -0.07), new("Q4", 0.36)];

var plot = new Plot
{
    Title = "Revenue and growth",
    SecondaryYScale = new LinearScale { TickFormat = NumberFormats.Percent() },
};
plot.Add(new BarMark<Share>(revenue, x: quarter => quarter.Name, y: quarter => quarter.Value) { Fill = _ => "Revenue" });
plot.Add(new LineMark<Share>(growth, x: quarter => quarter.Name, y: quarter => quarter.Value)
{
    UseSecondaryYScale = true,
    Stroke = _ => "Growth",
    StrokeWidth = 2.5,
    MarkerRadius = 4,
});
plot.YAxis.Label = "Revenue (k€)";
plot.SecondaryYAxis.Label = "Growth";

Bars of revenue with a line of growth on a percentage axis on the right

Reference lines

RuleYMark draws horizontal lines and RuleXMark<T> vertical lines, across the plot area.

var paris = SampleData.Readings.Where(reading => reading.City == "Paris");
var plot = new Plot { Title = "Months above 15 °C" };
plot.Add(new LineMark<Reading>(paris, x: reading => reading.Date, y: reading => reading.Temperature));
plot.Add(new RuleYMark(15) { StrokeColor = "#e5484d", StrokeDashArray = [4, 4] });
plot.Add(new RuleXMark<DateOnly>([new DateOnly(2024, 7, 1)], x: date => date) { StrokeColor = "#8c959f" });

A line chart with a dashed threshold and a vertical marker

Lollipop chart

A rule from zero to the value, then a dot at the value.

var sales = SampleData.Sales.Where(sale => sale.Year == 2024).ToArray();
var plot = new Plot { Title = "Lollipop" };
plot.Add(new RuleXMark<Sale>(sales, x: sale => sale.Region) { Y1 = _ => 0, Y2 = sale => sale.Amount, StrokeColor = "#8c959f" });
plot.Add(new DotMark<Sale>(sales, x: sale => sale.Region, y: sale => sale.Amount) { Radius = 6 });
plot.YScale = new LinearScale { Zero = true };

Stems from zero ending with dots

Dumbbell chart

A rule between two values and a dot at each end, in transposed coordinates.

var start = SampleData.Sales.Where(sale => sale.Year == 2022).ToDictionary(sale => sale.Region, sale => sale.Amount);
var end = SampleData.Sales.Where(sale => sale.Year == 2024).ToArray();
var plot = new Plot { Title = "From 2022 to 2024", Coordinates = new CartesianCoordinates { Transposed = true }, Height = 240 };
plot.Add(new RuleXMark<Sale>(end, x: sale => sale.Region) { Y1 = sale => start[sale.Region], Y2 = sale => sale.Amount, StrokeColor = "#8c959f", StrokeWidth = 2 });
plot.Add(new DotMark<Sale>(end, x: sale => sale.Region, y: sale => start[sale.Region]) { FillColor = "#9498a0", Radius = 5 });
plot.Add(new DotMark<Sale>(end, x: sale => sale.Region, y: sale => sale.Amount) { Radius = 5 });

Pairs of dots joined by a line for each region

Combo chart

Marks of different kinds share the scales of the plot: a line over bars puts its points in the middle of the bands. A constant category in a color channel names each layer in the legend.

var plot = new Plot { Title = "Sales in 2024, compared with 2023" };
plot.Add(new BarMark<Sale>(SampleData.Sales.Where(sale => sale.Year == 2024), x: sale => sale.Region, y: sale => sale.Amount)
{
    Fill = _ => "2024",
});
plot.Add(new LineMark<Sale>(SampleData.Sales.Where(sale => sale.Year == 2023), x: sale => sale.Region, y: sale => sale.Amount)
{
    Stroke = _ => "2023",
    MarkerRadius = 4,
    StrokeWidth = 2,
});

Bars of 2024 with a line of 2023 over them

Population pyramid

Transposed bars with negative values for one side. The tick format shows the absolute values.

public sealed record AgeGroup(string Age, string Sex, double Population);
string[] ages = ["80+", "60-79", "40-59", "20-39", "0-19"];
double[] men = [1.6, 7.1, 8.4, 7.9, 8.0];
double[] women = [2.7, 7.9, 8.6, 7.9, 7.6];
var groups = ages.SelectMany((age, i) => new[] { new AgeGroup(age, "Men", men[i]), new AgeGroup(age, "Women", women[i]) });

var plot = new Plot
{
    Title = "Population by age, in millions",
    Height = 280,
    Coordinates = new CartesianCoordinates { Transposed = true },
    YScale = new LinearScale { TickFormat = value => Math.Abs(value).ToString(CultureInfo.InvariantCulture) },
};

// The bars of the men go to the left: their values are negative
plot.Add(new BarMark<AgeGroup>(groups, x: group => group.Age, y: group => group.Sex == "Men" ? -group.Population : group.Population)
{
    Fill = group => group.Sex,
});
plot.Add(new RuleYMark(0));

Bars of men to the left and of women to the right, for five age groups

Slope chart

Lines over two categories of a point scale, labeled at their end with a TextMark<T>.

var sales = SampleData.Sales.Where(sale => sale.Year is 2022 or 2024).ToArray();
var plot = new Plot { Title = "From 2022 to 2024", Width = 360, Height = 380, XScale = new PointScale { Padding = 0.3 } };
plot.Add(new LineMark<Sale>(sales, x: sale => sale.Year.ToString(CultureInfo.InvariantCulture), y: sale => sale.Amount)
{
    Stroke = sale => sale.Region,
    MarkerRadius = 4,
});
plot.Add(new TextMark<Sale>(sales.Where(sale => sale.Year == 2024), text: sale => sale.Region, x: sale => "2024", y: sale => sale.Amount)
{
    Anchor = SceneTextAnchor.Start,
    OffsetX = 8,
    Fill = sale => sale.Region,
});
plot.Legend.Visible = false;

Four lines from 2022 to 2024 labeled by region

Error bars

A rule from Y1 to Y2 at each category, and a dot at the mean.

public sealed record GroupSummary(string Group, double Mean, double Deviation);
var summaries = SampleData.Measurements
    .GroupBy(measurement => measurement.Group)
    .Select(group =>
    {
        var mean = group.Average(measurement => measurement.Value);
        var deviation = Math.Sqrt(group.Average(measurement => Math.Pow(measurement.Value - mean, 2)));
        return new GroupSummary(group.Key, mean, deviation);
    })
    .ToArray();

var plot = new Plot { Title = "Mean and standard deviation", Height = 300 };
plot.Add(new RuleXMark<GroupSummary>(summaries, x: summary => summary.Group)
{
    Y1 = summary => summary.Mean - summary.Deviation,
    Y2 = summary => summary.Mean + summary.Deviation,
    StrokeWidth = 2,
});
plot.Add(new DotMark<GroupSummary>(summaries, x: summary => summary.Group, y: summary => summary.Mean) { Radius = 5 });

Dots at the means of three groups with vertical bars of one standard deviation

Gantt chart

A RectMark<T> from the start to the end of each task (X1 and X2, on a time scale), in the band of its name (Y).

public sealed record GanttTask(string Name, string Team, DateOnly Start, DateOnly End);
GanttTask[] tasks =
[
    new("Specification", "Product", new(2024, 1, 8), new(2024, 1, 26)),
    new("Design", "Design", new(2024, 1, 22), new(2024, 2, 16)),
    new("Backend", "Engineering", new(2024, 2, 5), new(2024, 3, 22)),
    new("Frontend", "Engineering", new(2024, 2, 19), new(2024, 4, 5)),
    new("Testing", "Quality", new(2024, 3, 18), new(2024, 4, 19)),
    new("Release", "Product", new(2024, 4, 15), new(2024, 4, 26)),
];

var plot = new Plot { Title = "Project plan", Height = 300 };
plot.Add(new RectMark<GanttTask>(tasks)
{
    X1 = task => task.Start,
    X2 = task => task.End,
    Y = task => task.Name,
    Fill = task => task.Team,
    Inset = 4,
    CornerRadius = 3,
    Title = task => $"{task.Name}: {task.Start:MMM d} - {task.End:MMM d}",
});
plot.Add(new RuleXMark<DateOnly>([new DateOnly(2024, 3, 1)], x: today => today) { StrokeColor = "#e5484d", StrokeDashArray = [4, 3] });
plot.XAxis.Grid = true;

Bars of six tasks on a time axis, colored by team, with a dashed line for today