#nullable enable
namespace Terminal.Gui;
/// View for rendering graphs (bar, scatter, etc...).
public class GraphView : View
{
/// Creates a new graph with a 1 to 1 graph space with absolute layout.
public GraphView ()
{
CanFocus = true;
AxisX = new HorizontalAxis ();
AxisY = new VerticalAxis ();
// Things this view knows how to do
AddCommand (
Command.ScrollUp,
() =>
{
Scroll (0, CellSize.Y);
return true;
}
);
AddCommand (
Command.ScrollDown,
() =>
{
Scroll (0, -CellSize.Y);
return true;
}
);
AddCommand (
Command.ScrollRight,
() =>
{
Scroll (CellSize.X, 0);
return true;
}
);
AddCommand (
Command.ScrollLeft,
() =>
{
Scroll (-CellSize.X, 0);
return true;
}
);
AddCommand (
Command.PageUp,
() =>
{
PageUp ();
return true;
}
);
AddCommand (
Command.PageDown,
() =>
{
PageDown ();
return true;
}
);
KeyBindings.Add (Key.CursorRight, Command.ScrollRight);
KeyBindings.Add (Key.CursorLeft, Command.ScrollLeft);
KeyBindings.Add (Key.CursorUp, Command.ScrollUp);
KeyBindings.Add (Key.CursorDown, Command.ScrollDown);
// Not bound by default (preserves backwards compatibility)
//KeyBindings.Add (Key.PageUp, Command.PageUp);
//KeyBindings.Add (Key.PageDown, Command.PageDown);
}
/// Elements drawn into graph after series have been drawn e.g. Legends etc.
public List Annotations { get; } = new ();
/// Horizontal axis.
///
public HorizontalAxis AxisX { get; set; }
/// Vertical axis.
///
public VerticalAxis AxisY { get; set; }
///
/// Translates console width/height into graph space. Defaults to 1 row/col of console space being 1 unit of graph
/// space.
///
///
public PointF CellSize { get; set; } = new (1, 1);
/// The color of the background of the graph and axis/labels.
public Attribute? GraphColor { get; set; }
///
/// Amount of space to leave on bottom of the graph. Graph content () will not be rendered in
/// margins but axis labels may be. Use to add a margin outside of the GraphView.
///
public uint MarginBottom { get; set; }
///
/// Amount of space to leave on left of the graph. Graph content () will not be rendered in
/// margins but axis labels may be. Use to add a margin outside of the GraphView.
///
public uint MarginLeft { get; set; }
///
/// The graph space position of the bottom left of the graph. Changing this scrolls the viewport around in the
/// graph.
///
///
public PointF ScrollOffset { get; set; } = new (0, 0);
/// Collection of data series that are rendered in the graph.
public List Series { get; } = new ();
#region Bresenham's line algorithm
// https://rosettacode.org/wiki/Bitmap/Bresenham%27s_line_algorithm#C.23
/// Draws a line between two points in screen space. Can be diagonals.
///
///
/// The symbol to use for the line
public void DrawLine (Point start, Point end, Rune symbol)
{
if (Equals (start, end))
{
return;
}
int x0 = start.X;
int y0 = start.Y;
int x1 = end.X;
int y1 = end.Y;
int dx = Math.Abs (x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = Math.Abs (y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = (dx > dy ? dx : -dy) / 2, e2;
while (true)
{
AddRune (x0, y0, symbol);
if (x0 == x1 && y0 == y1)
{
break;
}
e2 = err;
if (e2 > -dx)
{
err -= dy;
x0 += sx;
}
if (e2 < dy)
{
err += dx;
y0 += sy;
}
}
}
#endregion
/// Calculates the screen location for a given point in graph space. Bear in mind these may be off screen.
///
/// Point in graph space that may or may not be represented in the visible area of graph currently
/// presented. E.g. 0,0 for origin.
///
///
/// Screen position (Column/Row) which would be used to render the graph . Note that
/// this can be outside the current content area of the view.
///
public Point GraphSpaceToScreen (PointF location)
{
return new Point (
(int)((location.X - ScrollOffset.X) / CellSize.X) + (int)MarginLeft,
// screen coordinates are top down while graph coordinates are bottom up
Viewport.Height - 1 - (int)MarginBottom - (int)((location.Y - ScrollOffset.Y) / CellSize.Y)
);
}
///
public override void OnDrawContent (Rectangle viewport)
{
if (CellSize.X == 0 || CellSize.Y == 0)
{
throw new Exception ($"{nameof (CellSize)} cannot be 0");
}
SetDriverColorToGraphColor ();
Move (0, 0);
// clear all old content
for (var i = 0; i < Viewport.Height; i++)
{
Move (0, i);
Driver.AddStr (new string (' ', Viewport.Width));
}
// If there is no data do not display a graph
if (!Series.Any () && !Annotations.Any ())
{
return;
}
// The drawable area of the graph (anything that isn't in the margins)
int graphScreenWidth = Viewport.Width - (int)MarginLeft;
int graphScreenHeight = Viewport.Height - (int)MarginBottom;
// if the margins take up the full draw bounds don't render
if (graphScreenWidth < 0 || graphScreenHeight < 0)
{
return;
}
// Draw 'before' annotations
foreach (IAnnotation a in Annotations.ToArray ().Where (a => a.BeforeSeries))
{
a.Render (this);
}
SetDriverColorToGraphColor ();
AxisY.DrawAxisLine (this);
AxisX.DrawAxisLine (this);
AxisY.DrawAxisLabels (this);
AxisX.DrawAxisLabels (this);
// Draw a cross where the two axis cross
var axisIntersection = new Point (AxisY.GetAxisXPosition (this), AxisX.GetAxisYPosition (this));
if (AxisX.Visible && AxisY.Visible)
{
Move (axisIntersection.X, axisIntersection.Y);
AddRune (axisIntersection.X, axisIntersection.Y, (Rune)'\u253C');
}
SetDriverColorToGraphColor ();
var drawBounds = new Rectangle ((int)MarginLeft, 0, graphScreenWidth, graphScreenHeight);
RectangleF graphSpace = ScreenToGraphSpace (drawBounds);
foreach (ISeries s in Series.ToArray ())
{
s.DrawSeries (this, drawBounds, graphSpace);
// If a series changes the graph color reset it
SetDriverColorToGraphColor ();
}
SetDriverColorToGraphColor ();
// Draw 'after' annotations
foreach (IAnnotation a in Annotations.ToArray ().Where (a => !a.BeforeSeries))
{
a.Render (this);
}
}
///
/// Also ensures that cursor is invisible after entering the .
public override bool OnEnter (View view)
{
Driver.SetCursorVisibility (CursorVisibility.Invisible);
return base.OnEnter (view);
}
/// Scrolls the graph down 1 page.
public void PageDown () { Scroll (0, -1 * CellSize.Y * Viewport.Height); }
/// Scrolls the graph up 1 page.
public void PageUp () { Scroll (0, CellSize.Y * Viewport.Height); }
///
/// Clears all settings configured on the graph and resets all properties to default values (
/// , etc) .
///
public void Reset ()
{
ScrollOffset = new PointF (0, 0);
CellSize = new PointF (1, 1);
AxisX.Reset ();
AxisY.Reset ();
Series.Clear ();
Annotations.Clear ();
GraphColor = null;
SetNeedsDisplay ();
}
/// Returns the section of the graph that is represented by the given screen position.
///
///
///
public RectangleF ScreenToGraphSpace (int col, int row)
{
return new (
ScrollOffset.X + (col - MarginLeft) * CellSize.X,
ScrollOffset.Y + (Viewport.Height - (row + MarginBottom + 1)) * CellSize.Y,
CellSize.X,
CellSize.Y
);
}
/// Returns the section of the graph that is represented by the screen area.
///
///
public RectangleF ScreenToGraphSpace (Rectangle screenArea)
{
// get position of the bottom left
RectangleF pos = ScreenToGraphSpace (screenArea.Left, screenArea.Bottom - 1);
return pos with { Width = screenArea.Width * CellSize.X, Height = screenArea.Height * CellSize.Y };
}
///
/// Scrolls the view by a given number of units in graph space. See to translate this into
/// rows/cols.
///
///
///
public void Scroll (float offsetX, float offsetY)
{
ScrollOffset = new (
ScrollOffset.X + offsetX,
ScrollOffset.Y + offsetY
);
SetNeedsDisplay ();
}
///
/// Sets the color attribute of to the (if defined) or
/// otherwise.
///
public void SetDriverColorToGraphColor () { Driver.SetAttribute (GraphColor ?? GetNormalColor ()); }
}