Skip to main content

Charts

Three widgets that draw numbers instead of text. Like the indicators, they only draw: none of them takes the focus, all of them implement IArlecchinoWidget, and each hands back the rows below what it drew.

All three measure in decimal, the same type ProgressBar and the value modals use.

Sparkline

A series as one row of blocks — the shape of the line, with no axis, no scale and no grid, which is what lets it sit in a status bar or a corner of a pane:

private readonly List<decimal> _history = [];

private readonly Sparkline _downloads = new()
{
Values = _history,
Caption = static value => $"{value:0}/s",
};

_downloads.Draw(region.Rows(0, 1));
MemberMeaning
ValuesThe series, oldest first. Held, not copied
Minimum / MaximumWhat the lowest and tallest block stand for. The drawn values themselves when left alone
CaptionTurns the newest value into the text after the line
StyleColors the line

The newest value is the rightmost, and only the last of them fit the row, so a wider terminal shows more history rather than a wider drawing of the same history. Nothing is copied out of Values, so a ring buffer the application appends to between frames is exactly the right thing to hand over.

Leaving the range alone makes the line fill the row, which answers how does it move. Pinning it answers how big is it instead, and keeps a line still when the numbers barely change:

private readonly Sparkline _failures = new() { Values = _errors, Minimum = 0 };

A series with no spread at all — every number the same, or one number on its own — draws as the lowest block rather than as a full row.

AreaChart

The same series as a Sparkline, but filling a pane instead of a row — the shape a system monitor shows:

private readonly AreaChart _cpu = new()
{
Values = _history,
Minimum = 0,
Maximum = 100,
Bands = [new(0m, Theme.Active), new(60m, Theme.Warning), new(85m, Theme.Error)],
};

_cpu.Draw(region);
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣶⣶⣶⣦⡀⠀⠀⠀
⠀⠀⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀
⠀⠀⣠⣾⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀
⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
MemberMeaning
ValuesThe series, oldest first. Held, not copied
Minimum / MaximumWhat empty and full stand for. The drawn values themselves when left alone
SymbolsWhat to draw with; the application's own setting when left alone
BandsWhere the color changes as the fill climbs, in the same units as the values
StyleColors the fill where no band covers it
InvertHangs it from the top, for the second half of a mirrored pair

The resolution is in the characters: a cell carries two samples side by side and several levels of height, so a chart eight rows tall has thirty-two levels between empty and full and holds twice the history a row of blocks would. The newest value is at the right, and a series with no spread at all draws as the lowest level along the bottom rather than as nothing.

Color comes from how high the fill climbed rather than from anything the view works out. A terminal with truecolor blends between the bands, a 256-color one quantizes that blend, and one with no color draws the shape alone.

Which symbols

services.AddArlecchino(options => options.GraphSymbols = GraphSymbols.Braille);

Glyphs.Graph = GraphSymbols.Blocks; // later, from a settings screen
SetResolutionNeeds
Braille4 levels, 2 samples per cellA font carrying Braille Patterns, or a terminal that falls back to one
Blocks2 levels, 2 samples per cellQuadrant blocks, in nearly every monospace font
Tty3 levels, 1 sample per cell░▒█ and little else

Glyphs.Graph is process-wide and settable, the same arrangement as Theme.Palette, so an application can offer the choice in its own settings and every chart follows on the next frame — frames are drawn from scratch, so nothing has to be invalidated. A change made outside the input path should ask for a frame with Repaint.Request(), since nothing else will.

Braille and fonts

This is a font question rather than a platform one. Windows Terminal falls back per glyph, so braille renders even when the configured font has none of it; the classic console host does not, and shows boxes instead. Blocks is the safe middle and still twice the density of a Sparkline.

BarChart

One bar per item, laid out down the region: the label in front, the bar across the middle, the readout behind.

private sealed record Mirror(string Name, decimal Megabytes);

private readonly Mirror[] _mirrors = [...];

private readonly BarChart<Mirror> _traffic = new()
{
Render = static mirror => mirror.Name,
Value = static mirror => mirror.Megabytes,
Items = _mirrors,
Caption = static value => $"{value:0}",
ItemStyle = static mirror => mirror.Megabytes < 100m ? Theme.Secondary : Theme.Active,
};

var rest = _traffic.Draw(region);
europe-west ████████████████████████████████████ 812
us-east █████████████████████████░░░░░░░░░░░ 640
asia-south █████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 227
cdn-fallback ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 58
MemberMeaning
RenderTurns an item into its label. Required
ValueThe number the length of the bar stands for. Required
ItemsWhat to chart, one bar per row
MaximumThe value at which a bar is full. The largest item when left alone
CaptionTurns a value into the readout after its bar
ItemStyleColors one bar
LabelWidthColumns kept for labels. The widest label when left alone, up to a third of the region

Bars are measured against the largest item, so a chart of things that are all small still fills the pane. Pin Maximum when the point is to compare one frame against the next, or to keep a percentage chart honest before anything has reached 100.

The readouts share one column, as wide as the longest of them, so the numbers line up under one another. Labels longer than the label column are truncated by column, never by character, so a wide character is not cut in half.

The chart does not scroll: items past the bottom of the region are simply not drawn, which is what keeps it readable without the focus. Put it in a ScrollPane when there are more things than rows — or, more usually, chart the top few and list the rest.

Gauge

One value against a range that means something, colored by the bands it crosses:

private readonly Gauge _disk = new()
{
Value = 91,
Caption = static value => $"{value:0}%",
Bands = [new(0m, Theme.Active), new(70m, Theme.Warning), new(90m, Theme.Error)],
};

_disk.Draw(region.Rows(0, 1));
MemberMeaning
ValueWhat it reads now. Outside the range it draws empty or full
Minimum / MaximumThe ends of the range. Default to 0 and 100
BandsWhere the colors change, in ascending order
CaptionTurns the value into the text after the track
StyleColors the fill outside every band
FractionHow full it is, 0 to 1
StyleAt(value)The style that value is drawn in

A GaugeBand is a value and a style, and it runs up to the start of the next band, so the list is given in ascending order and the first band decides the color of everything below it. Each part of the fill keeps the color of the band it lies in, so the tail of the bar shows how far past the line the value has gone — and the caption takes the color of the band the value itself is in.

StyleAt is the same lookup the fill uses, which is how a label beside the gauge is colored to match it:

region.Write(0, 0, "disk", _disk.StyleAt(_disk.Value));

Without bands the whole fill takes Style, which makes the gauge a progress bar with a range of its own.

Gauge or ProgressBar?

A ProgressBar answers how far along, and a Gauge answers how bad is it now. The difference is the bands, and a range that need not start at zero. Neither is a subset of the other, so pick by the question the screen is asking.

What they do not do

There is no plot with axes, ticks and several series — no line chart. These three are meant to be read at a glance next to the text they belong to, which is why they carry no chrome of their own. Put one in a titled pane when it needs a caption:

_layout = Branch(
Rows,
0.5,
Leaf(_traffic, static () => "downloads by mirror, MB"),
Leaf(_downloads, static () => "last 20 minutes")).Gaps(inner: 1, outer: 1);

See Layout for what Branch and Leaf are.