Skip to content

GraphNode

A reactive, self-invalidating layout node that renders live scrolling graphs with optional gradient coloring. Implements IAnimatedNode so only the graph region repaints — not the parent layout.

Demo — Live graph with gradient coloring, cycling through all 4 styles

Graph Gallery demo cycling through Blocks, Outline, Braille, and ASCII styles with gradient coloring

Basic Usage

csharp
var graph = new GraphNode()
    .WithColor(Color.Cyan)
    .WithRange(0, 100);

graph.SetData([10, 40, 70, 100, 60, 30]);

Graph Styles

Four rendering styles are available:

csharp
new GraphNode().WithStyle(GraphStyle.Blocks)   // ▁▂▃▄▅▆▇█ filled columns
new GraphNode().WithStyle(GraphStyle.Outline)  // Only the top edge drawn
new GraphNode().WithStyle(GraphStyle.Braille)  // Double vertical resolution
new GraphNode().WithStyle(GraphStyle.Ascii)    // _ . - ~ ^ * # @ fallback
StyleCharactersBest for
Blocks▁▂▃▄▅▆▇█General use, high contrast
OutlineTop edge onlySparse data, sparkline feel
Braille⠀⢀⣀⣤⣶⣿Double vertical resolution per row
Ascii_ . - ~ ^ * # @Terminals without Unicode

Gradient Coloring

Apply a gradient that maps color by row height:

csharp
var gradient = Gradient.Create(
    Color.FromRgb(0, 100, 255),   // blue at the bottom
    Color.FromRgb(0, 255, 100),   // green in the middle
    Color.FromRgb(255, 255, 0));  // yellow at the top

var graph = new GraphNode()
    .WithGradient(gradient)
    .WithRange(0, 100);

For a single color, use the convenience method:

csharp
new GraphNode().WithColor(Color.Green)

Data-Driven Updates

SetData fires invalidation immediately so the graph repaints as soon as data arrives, independent of the internal timer interval:

csharp
Observable.Interval(TimeSpan.FromMilliseconds(200), TimeProvider.System)
    .ObserveOn(RenderFrameProvider)
    .Subscribe(_ =>
    {
        dataPoints.Add(GetNextValue());
        graph.SetData(dataPoints.ToArray());
    });

The graph renders the rightmost width data points (or width * 2 for Braille style). Earlier data scrolls off the left edge.

Internal Timer

The constructor accepts an interval for periodic self-invalidation. Set intervalMs: 0 to disable the internal timer entirely and rely only on SetData for repaints:

csharp
// Timer-driven refresh (default 500ms), invalidated on the Termina loop
new GraphNode(intervalMs: 500)

// Data-driven only — no timer overhead
new GraphNode(intervalMs: 0)

Testable Timing

GraphNode receives timing through LayoutRuntimeContext. In app code this happens automatically when the graph is attached to a page layout tree. For component-level tests, set a test runtime context before activation.

API Reference

Constructor

csharp
public GraphNode(int intervalMs = 500)

Methods

MethodDescription
.WithStyle(GraphStyle)Set rendering style
.WithGradient(Gradient)Apply gradient coloring by row
.WithColor(Color)Single-color convenience
.WithRange(double min, double max)Set data value range (default 0–100)
.SetData(double[])Push data and trigger repaint
.Start()Start internal timer
.Stop()Stop internal timer

GraphStyle Enum

StyleDescription
BlocksFilled block columns (default)
OutlineTop edge only
BrailleDouble resolution braille dots
AsciiASCII fallback characters

Source Code

View GraphNode implementation
csharp
using R3;
using Termina.Rendering;
using Termina.Terminal;

namespace Termina.Layout;

/// <summary>
/// Reactive LayoutNode that renders a live scrolling graph with optional gradient coloring.
/// Uses IAnimatedNode (like SpinnerNode) so only this region is invalidated,
/// NOT the parent layout.
/// </summary>
public sealed class GraphNode : LayoutNode, IAnimatedNode, IInvalidatingNode
{
    private static readonly char[] BlockChars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
    private static readonly char[] OutlineChars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '▔'];
    private static readonly char[] AsciiChars = [' ', '_', '.', '-', '~', '^', '*', '#', '@'];

    private static readonly char[][] BrailleChars =
    [
        ['⠀', '⢀', '⢠', '⢰', '⢸'],
        ['⡀', '⣀', '⣠', '⣰', '⣸'],
        ['⡄', '⣄', '⣤', '⣴', '⣼'],
        ['⡆', '⣆', '⣦', '⣶', '⣾'],
        ['⡇', '⣇', '⣧', '⣷', '⣿'],
    ];

    private readonly Subject<Unit> _invalidated = new();
    private readonly int _intervalMs;
    private IDisposable? _timerSubscription;
    private double[] _data = [];
    private GraphStyle _style = GraphStyle.Blocks;
    private Gradient? _gradient;
    private double _minValue;
    private double _maxValue = 100;

    public Observable<Unit> Invalidated => _invalidated.AsObservable();
    public bool IsAnimating { get; private set; }

    public GraphNode(int intervalMs = 500)
    {
        _intervalMs = intervalMs;
    }

    /// <summary>
    /// Push new data to render. Immediately fires <see cref="Invalidated"/> so the node
    /// repaints as soon as data arrives, regardless of the internal timer interval.
    /// </summary>
    public GraphNode SetData(double[] data)
    {
        _data = data;
        _invalidated.OnNext(Unit.Default);
        return this;
    }

    public void Start()
    {
        if (IsAnimating || _intervalMs <= 0)
        {
            return;
        }

        var ticks = Observable.Interval(TimeSpan.FromMilliseconds(_intervalMs), GetTimeProvider());
        if (GetFrameProvider() is { } frameProvider)
            ticks = ticks.ObserveOn(frameProvider);

        _timerSubscription ??= ticks
            .Subscribe(_ => { _invalidated.OnNext(Unit.Default); });
        IsAnimating = true;
    }

    public override void SetRuntimeContext(LayoutRuntimeContext context)
    {
        var restart = IsAnimating;
        if (restart)
            Stop();

        base.SetRuntimeContext(context);

        if (restart)
            Start();
    }

    private TimeProvider GetTimeProvider() => RuntimeContext?.TimeProvider ?? TimeProvider.System;

    private FrameProvider? GetFrameProvider() => RuntimeContext?.RenderFrameProvider;

    public void Stop()
    {
        if (!IsAnimating)
        {
            return;
        }

        _timerSubscription?.Dispose();
        _timerSubscription = null;
        IsAnimating = false;
    }

    public GraphNode WithStyle(GraphStyle style)
    {
        _style = style;
        return this;
    }

    /// <summary>
    /// Apply a gradient for vertical coloring. Each row samples the gradient
    /// at <c>row / (height - 1)</c>.
    /// </summary>
    public GraphNode WithGradient(Gradient gradient)
    {
        _gradient = gradient;
        return this;
    }

    /// <summary>
    /// Convenience: creates a single-color gradient.
    /// </summary>
    public GraphNode WithColor(Color color)
    {
        _gradient = Gradient.Create(color, color);
        return this;
    }

    public GraphNode WithRange(double min, double max)
    {
        _minValue = min;
        _maxValue = max;
        return this;
    }

    public override Size Measure(Size available)
    {
        var w = WidthConstraint.Compute(available.Width, available.Width, available.Width);
        var h = HeightConstraint.Compute(available.Height, available.Height, available.Height);
        return new Size(w, h);
    }

    public override void Render(IRenderContext context, Rect bounds)
    {
        if (!bounds.HasArea)
        {
            return;
        }

        var width = bounds.Width;
        var height = bounds.Height;

        var maxPoints = _style == GraphStyle.Braille ? width * 2 : width;
        var data = _data;

        // Create a sub-context clipped to our bounds.
        var ctx = context.CreateSubContext(bounds);

        switch (_style)
        {
            case GraphStyle.Blocks: RenderColumns(ctx, data, width, height, BlockChars); break;
            case GraphStyle.Outline: RenderOutline(ctx, data, width, height); break;
            case GraphStyle.Braille: RenderBraille(ctx, data, width, height); break;
            case GraphStyle.Ascii: RenderColumns(ctx, data, width, height, AsciiChars); break;
        }

        ctx.ResetColors();
    }

    private void RenderColumns(IRenderContext ctx, double[] data, int width, int height, char[] chars)
    {
        for (var col = 0; col < width; col++)
        {
            var dataIdx = data.Length - width + col;
            var filledRows = dataIdx >= 0 ? Normalize(data[dataIdx]) * height : 0.0;

            for (var row = 0; row < height; row++)
            {
                var y = height - 1 - row;

                int charIdx;
                if (filledRows >= row + 1)
                {
                    charIdx = 8;
                }
                else if (filledRows > row)
                {
                    charIdx = Math.Clamp((int)Math.Ceiling((filledRows - row) * 7.999), 1, 8);
                }
                else
                {
                    charIdx = 0;
                }

                if (charIdx > 0 && _gradient is not null)
                {
                    var t = height > 1 ? row / (float)(height - 1) : 0f;
                    ctx.SetForeground(_gradient.Sample(t));
                }

                ctx.WriteAt(col, y, chars[Math.Min(charIdx, chars.Length - 1)]);
                ctx.ResetColors();
            }
        }
    }

    private void RenderOutline(IRenderContext ctx, double[] data, int width, int height)
    {
        for (var col = 0; col < width; col++)
        {
            var dataIdx = data.Length - width + col;
            var filledRows = dataIdx >= 0 ? Normalize(data[dataIdx]) * height : 0.0;
            var edgeRow = (int)filledRows;

            for (var row = 0; row < height; row++)
            {
                var y = height - 1 - row;

                if (row == edgeRow && filledRows > 0)
                {
                    var charIdx = Math.Clamp((int)Math.Ceiling((filledRows - row) * 7.999), 1, 8);
                    if (_gradient is not null)
                    {
                        var t = height > 1 ? row / (float)(height - 1) : 0f;
                        ctx.SetForeground(_gradient.Sample(t));
                    }

                    ctx.WriteAt(col, y, OutlineChars[charIdx]);
                    ctx.ResetColors();
                }
                else
                {
                    ctx.WriteAt(col, y, ' ');
                }
            }
        }
    }

    private void RenderBraille(IRenderContext ctx, double[] data, int width, int height)
    {
        var subHeight = height * 2;

        for (var col = 0; col < width; col++)
        {
            var botIdx = data.Length - width * 2 + col * 2;
            var topIdx = botIdx + 1;

            var botFilled = botIdx >= 0 && botIdx < data.Length ? Normalize(data[botIdx]) * subHeight : 0.0;
            var topFilled = topIdx >= 0 && topIdx < data.Length ? Normalize(data[topIdx]) * subHeight : 0.0;

            for (var row = 0; row < height; row++)
            {
                var y = height - 1 - row;

                var botSubRow = row * 2;
                var topSubRow = row * 2 + 1;
                var botFill = (int)Math.Clamp((botFilled - botSubRow) * 4, 0, 4);
                var topFill = (int)Math.Clamp((topFilled - topSubRow) * 4, 0, 4);
                var c = BrailleChars[botFill][topFill];

                if (c != '⠀' && _gradient is not null)
                {
                    var t = height > 1 ? row / (float)(height - 1) : 0f;
                    ctx.SetForeground(_gradient.Sample(t));
                }

                ctx.WriteAt(col, y, c);
                ctx.ResetColors();
            }
        }
    }

    private double Normalize(double value)
    {
        var range = _maxValue - _minValue;
        if (range <= 0)
        {
            return 0;
        }

        return Math.Clamp((value - _minValue) / range, 0.0, 1.0);
    }

    public override void OnActivate()
    {
        Start();
        base.OnActivate();
    }

    public override void OnDeactivate()
    {
        Stop();
        base.OnDeactivate();
    }

    public override void Dispose()
    {
        Stop();
        _invalidated.OnCompleted();
        _invalidated.Dispose();
        base.Dispose();
    }
}

Released under the Apache 2.0 License.