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.