From a8628e7c28f7305d675e7660121a1540150644c9 Mon Sep 17 00:00:00 2001 From: Thomas Nind <31306100+tznind@users.noreply.github.com> Date: Wed, 28 Apr 2021 16:38:25 +0100 Subject: [PATCH] Feature/graphs (#1201) * Empty GraphView with basic axis * Added ISeries * Added zoom * Fixed zoom * Tests and scrolling * Refactored AxisView into abstract base * Added atomic mass example * Added Y axis labels * Added Y axis labels * comments * Refactored axis to not be floating views * Split axis drawing code to seperate draw line from draw labels * Added MarginBottom and MarginLeft * Added bar graph * Fixes horizontal axis label generation * Fixed axis labels changing during scrolling * Added test for overlapping cells * Added TestReversing_ScreenToGraphSpace * Changed graph space from float to decimal * Added axis labels * Fixed issues where labels/axis overspilled bounds * Fixed origin screen coordinates being off by 1 in y axis * Added Orientation to BarSeries * Added comments and standardised Name to Text * Added prototype 'population pyramid' * Fixed bar graphs not stopping at axis * Added Reset and Ctrl to speed up scrolling * Added line graph * Fixed LineSeries implementation * Made LineSeries Points readonly and sort on add * Fixed RectangleD.GetHasCode() * Improved performance of LineSeries * Added color to graph * Fixed colors not working on linux * Added Visible and ColorGetter * Added Ctrl+G Next Graph * Added MultiBarSeries * Fixed layout issue with population pyramid * fixed y label overspill and origin rendering * Fixed warnings * Made examples prettier * Fixed xAxis potentially drawing labels outside of control area * Fixed multi bar example labels * Added IAnnotation * Added example of using GraphPosition in IAnnotation * Fixed Annotations drawing outside of graph bounds * Fixed Reset() not clearing Annotations and sp fixes * Changed line drawing to Bresenham's line algorithm and deleted CohenSutherland Testing for collisions with screen space is very slow and gives quite thick lines. I looked at Xiaolin Wu which supports anti aliasing but this also would require more work to look good (out of the box it just looks thick). * Fixed layout/whitespace * Graph now renders without series if annotations are present * Fixed ScreenToGraphSpace rect overload * Added SeriesDrawMode for when it is easier/faster for a series to draw itself all in one go * Added LegendAnnotation * Added tests for correct bounds * Added more tests * Changed GraphView namespace to Terminal.Gui.Graphs * Made Line2D and Horizontal/Vertical axis private classes * Made AxisIncrementToRender.Text internal to avoid confusing user when implementing `LabelGetterDelegate` * Changed back from decimal to float * Refactored axis label drawing to avoid rounding errors * Fixed over spilling bounds when drawing bars/axis increments * Re-implemented disco colors * Added Minimum to Axis * Fixed tests build and render order * Fixed test by adjusting epsilon * tidyup, docs and warning fixes * Standardised parameter order and added axis test * Fixed x axis line drawing into margins and added tests * Fixed axis increment rendering in margins, tests and tidyup examples * Added test for BarSeries * Added more BarSeriesTests * Split GraphView.cs into sub files as suggested * Fixed pointlessly passing around ConsoleDriver and Bounds * Fixed colored bars not reseting color before drawing labels * spelling fixes * Replaced System.Drawing with code copied from dotnet/corefx repo * Change to trigger CI * Added tests for MultiBarSeries * Added test support for Asserting exact graph contents * Added xml doc where missing from System.Drawing Types * Standardised unit test namespaces to Terminal.Gui * Fixed namespace correctly this time after merging main * Fixed test to avoid using Attribute constructor * Reduced code duplication in test by moving InitFakeDriver to static in GraphViewTests * Added TextAnnotationTests and improved GraphViewTests.AssertDriverContentsAre * Added more TextAnnotation tests and fixed file indentation * Added tests for Legend and Path And fixed TruncateOrPad being off by 1 when truncating * Removed unused paths in TruncateOrPad --- .../ConsoleDrivers/FakeDriver/FakeDriver.cs | 5 + Terminal.Gui/Core/Graphs/Annotations.cs | 310 ++++ Terminal.Gui/Core/Graphs/Axis.cs | 565 +++++++ Terminal.Gui/Core/Graphs/GraphCellToRender.cs | 45 + Terminal.Gui/Core/Graphs/Orientation.cs | 17 + Terminal.Gui/Core/Graphs/Series.cs | 323 ++++ Terminal.Gui/Types/PointF.cs | 138 ++ Terminal.Gui/Types/RectangleF.cs | 303 ++++ Terminal.Gui/Types/SizeF.cs | 168 +++ Terminal.Gui/Views/GraphView.cs | 318 ++++ UICatalog/Scenarios/GraphViewExample.cs | 685 +++++++++ UnitTests/GraphViewTests.cs | 1307 +++++++++++++++++ UnitTests/TabViewTests.cs | 1 + UnitTests/TableViewTests.cs | 1 + UnitTests/TreeViewTests.cs | 1 + 15 files changed, 4187 insertions(+) create mode 100644 Terminal.Gui/Core/Graphs/Annotations.cs create mode 100644 Terminal.Gui/Core/Graphs/Axis.cs create mode 100644 Terminal.Gui/Core/Graphs/GraphCellToRender.cs create mode 100644 Terminal.Gui/Core/Graphs/Orientation.cs create mode 100644 Terminal.Gui/Core/Graphs/Series.cs create mode 100644 Terminal.Gui/Types/PointF.cs create mode 100644 Terminal.Gui/Types/RectangleF.cs create mode 100644 Terminal.Gui/Types/SizeF.cs create mode 100644 Terminal.Gui/Views/GraphView.cs create mode 100644 UICatalog/Scenarios/GraphViewExample.cs create mode 100644 UnitTests/GraphViewTests.cs diff --git a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs index f7d299181..77c88094c 100644 --- a/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs @@ -26,6 +26,11 @@ namespace Terminal.Gui { int [,,] contents; bool [] dirtyLine; + /// + /// Assists with testing, the format is rows, columns and 3 values on the last column: Rune, Attribute and Dirty Flag + /// + public int [,,] Contents => contents; + void UpdateOffscreen () { int cols = Cols; diff --git a/Terminal.Gui/Core/Graphs/Annotations.cs b/Terminal.Gui/Core/Graphs/Annotations.cs new file mode 100644 index 000000000..720c4a7ff --- /dev/null +++ b/Terminal.Gui/Core/Graphs/Annotations.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Terminal.Gui.Graphs { + /// + /// Describes an overlay element that is rendered either before or + /// after a series. + /// + /// Annotations can be positioned either in screen space (e.g. + /// a legend) or in graph space (e.g. a line showing high point) + /// + /// Unlike , annotations are allowed to + /// draw into graph margins + /// + /// + public interface IAnnotation { + /// + /// True if annotation should be drawn before . This + /// allowes Series and later annotations to potentially draw over the top + /// of this annotation. + /// + bool BeforeSeries { get; } + + /// + /// Called once after series have been rendered (or before if is true). + /// Use to draw and to avoid drawing outside of + /// graph + /// + /// + void Render (GraphView graph); + } + + + /// + /// Displays text at a given position (in screen space or graph space) + /// + public class TextAnnotation : IAnnotation { + + /// + /// The location on screen to draw the regardless + /// of scroll/zoom settings. This overrides + /// if specified. + /// + public Point? ScreenPosition { get; set; } + + /// + /// The location in graph space to draw the . This + /// annotation will only show if the point is in the current viewable + /// area of the graph presented in the + /// + public PointF GraphPosition { get; set; } + + /// + /// Text to display on the graph + /// + public string Text { get; set; } + + /// + /// True to add text before plotting series. Defaults to false + /// + public bool BeforeSeries { get; set; } + + /// + /// Draws the annotation + /// + /// + public void Render (GraphView graph) + { + if (ScreenPosition.HasValue) { + DrawText (graph, ScreenPosition.Value.X, ScreenPosition.Value.Y); + return; + } + + var screenPos = graph.GraphSpaceToScreen (GraphPosition); + DrawText (graph, screenPos.X, screenPos.Y); + } + + /// + /// Draws the at the given coordinates with truncation to avoid + /// spilling over of the + /// + /// + /// Screen x position to start drawing string + /// Screen y position to start drawing string + protected void DrawText (GraphView graph, int x, int y) + { + // the draw point is out of control bounds + if (!graph.Bounds.Contains (new Point (x, y))) { + return; + } + + // There is no text to draw + if (string.IsNullOrWhiteSpace (Text)) { + return; + } + + graph.Move (x, y); + + int availableWidth = graph.Bounds.Width - x; + + if (availableWidth <= 0) { + return; + } + + if (Text.Length < availableWidth) { + View.Driver.AddStr (Text); + } else { + View.Driver.AddStr (Text.Substring (0, availableWidth)); + } + } + } + + /// + /// A box containing symbol definitions e.g. meanings for colors in a graph. + /// The 'Key' to the graph + /// + public class LegendAnnotation : IAnnotation { + + /// + /// True to draw a solid border around the legend. + /// Defaults to true. This border will be within the + /// and so reduces the width/height + /// available for text by 2 + /// + public bool Border { get; set; } = true; + + /// + /// Defines the screen area available for the legend to render in + /// + public Rect Bounds { get; set; } + + /// + /// Returns false i.e. Lengends render after series + /// + public bool BeforeSeries => false; + + /// + /// Ordered collection of entries that are rendered in the legend. + /// + List> entries = new List> (); + + /// + /// Creates a new empty legend at the given screen coordinates + /// + /// Defines the area available for the legend to render in + /// (within the graph). This is in screen units (i.e. not graph space) + public LegendAnnotation (Rect legendBounds) + { + Bounds = legendBounds; + } + + /// + /// Draws the Legend and all entries into the area within + /// + /// + public void Render (GraphView graph) + { + if (Border) { + graph.DrawFrame (Bounds, 0, true); + } + + // start the legend at + int y = Bounds.Top + (Border ? 1 : 0); + int x = Bounds.Left + (Border ? 1 : 0); + + // how much horizontal space is available for writing legend entries? + int availableWidth = Bounds.Width - (Border ? 2 : 0); + int availableHeight = Bounds.Height - (Border ? 2 : 0); + + int linesDrawn = 0; + + foreach (var entry in entries) { + + if (entry.Item1.Color.HasValue) { + Application.Driver.SetAttribute (entry.Item1.Color.Value); + } else { + graph.SetDriverColorToGraphColor (); + } + + // add the symbol + graph.AddRune (x, y + linesDrawn, entry.Item1.Rune); + + // switch to normal coloring (for the text) + graph.SetDriverColorToGraphColor (); + + // add the text + graph.Move (x + 1, y + linesDrawn); + + string str = TruncateOrPad (entry.Item2, availableWidth - 1); + Application.Driver.AddStr (str); + + linesDrawn++; + + // Legend has run out of space + if (linesDrawn >= availableHeight) { + break; + } + } + } + + private string TruncateOrPad (string text, int width) + { + if (string.IsNullOrEmpty (text)) + return text; + + // if value is not wide enough + if (text.Sum (c => Rune.ColumnWidth (c)) < width) { + + // pad it out with spaces to the given alignment + int toPad = width - (text.Sum (c => Rune.ColumnWidth (c))); + + return text + new string (' ', toPad); + } + + // value is too wide + return new string (text.TakeWhile (c => (width -= Rune.ColumnWidth (c)) >= 0).ToArray ()); + } + + /// + /// Adds an entry into the legend. Duplicate entries are permissable + /// + /// The symbol appearing on the graph that should appear in the legend + /// Text to render on this line of the legend. Will be truncated + /// if outside of Legend + public void AddEntry (GraphCellToRender graphCellToRender, string text) + { + entries.Add (Tuple.Create (graphCellToRender, text)); + } + } + + /// + /// Sequence of lines to connect points e.g. of a + /// + public class PathAnnotation : IAnnotation { + + /// + /// Points that should be connected. Lines will be drawn between points in the order + /// they appear in the list + /// + public List Points { get; set; } = new List (); + + /// + /// Color for the line that connects points + /// + public Attribute? LineColor { get; set; } + + /// + /// The symbol that gets drawn along the line, defaults to '.' + /// + public Rune LineRune { get; set; } = new Rune ('.'); + + /// + /// True to add line before plotting series. Defaults to false + /// + public bool BeforeSeries { get; set; } + + + /// + /// Draws lines connecting each of the + /// + /// + public void Render (GraphView graph) + { + View.Driver.SetAttribute (LineColor ?? graph.ColorScheme.Normal); + + foreach (var line in PointsToLines ()) { + + var start = graph.GraphSpaceToScreen (line.Start); + var end = graph.GraphSpaceToScreen (line.End); + graph.DrawLine (start, end, LineRune); + } + } + + /// + /// Generates lines joining + /// + /// + private IEnumerable PointsToLines () + { + for (int i = 0; i < Points.Count - 1; i++) { + yield return new LineF (Points [i], Points [i + 1]); + } + } + + /// + /// Describes two points in graph space and a line between them + /// + public class LineF { + /// + /// The start of the line + /// + public PointF Start { get; } + + /// + /// The end point of the line + /// + public PointF End { get; } + + /// + /// Creates a new line between the points + /// + public LineF (PointF start, PointF end) + { + this.Start = start; + this.End = end; + } + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Core/Graphs/Axis.cs b/Terminal.Gui/Core/Graphs/Axis.cs new file mode 100644 index 000000000..5c6d3223d --- /dev/null +++ b/Terminal.Gui/Core/Graphs/Axis.cs @@ -0,0 +1,565 @@ +using System; +using System.Collections.Generic; + +namespace Terminal.Gui.Graphs { + + /// + /// Renders a continuous line with grid line ticks and labels + /// + public abstract class Axis { + /// + /// Default value for + /// + const uint DefaultShowLabelsEvery = 5; + + /// + /// Direction of the axis + /// + /// + public Orientation Orientation { get; } + + /// + /// Number of units of graph space between ticks on axis. 0 for no ticks + /// + /// + public float Increment { get; set; } = 1; + + /// + /// The number of before an label is added. + /// 0 = never show labels + /// + public uint ShowLabelsEvery { get; set; } = DefaultShowLabelsEvery; + + /// + /// True to render axis. Defaults to true + /// + public bool Visible { get; set; } = true; + + /// + /// Allows you to control what label text is rendered for a given + /// when is above 0 + /// + public LabelGetterDelegate LabelGetter; + + /// + /// Displayed below/to left of labels (see ). + /// If text is not visible, check / + /// + public string Text; + + /// + /// The minimum axis point to show. Defaults to null (no minimum) + /// + public float? Minimum { get; set; } + + /// + /// Populates base properties and sets the read only + /// + /// + protected Axis (Orientation orientation) + { + Orientation = orientation; + LabelGetter = DefaultLabelGetter; + } + + /// + /// Draws the solid line of the axis + /// + /// + public abstract void DrawAxisLine (GraphView graph); + + /// + /// Draws a single cell of the solid line of the axis + /// + /// + /// + /// + protected abstract void DrawAxisLine (GraphView graph, int x, int y); + + /// + /// Draws labels and axis ticks + /// + /// + + public abstract void DrawAxisLabels (GraphView graph); + + /// + /// Draws a custom label at units + /// along the axis (X or Y depending on ) + /// + /// + /// + /// + public abstract void DrawAxisLabel (GraphView graph, int screenPosition, string text); + + /// + /// Resets all configurable properties of the axis to default values + /// + public virtual void Reset () + { + Increment = 1; + ShowLabelsEvery = DefaultShowLabelsEvery; + Visible = true; + Text = ""; + LabelGetter = DefaultLabelGetter; + Minimum = null; + } + + private string DefaultLabelGetter (AxisIncrementToRender toRender) + { + return toRender.Value.ToString ("N0"); + } + } + + /// + /// The horizontal (x axis) of a + /// + public class HorizontalAxis : Axis { + + /// + /// Creates a new instance of axis with an of + /// + public HorizontalAxis () : base (Orientation.Horizontal) + { + } + + + /// + /// Draws the horizontal axis line + /// + /// + public override void DrawAxisLine (GraphView graph) + { + if (!Visible) { + return; + } + var bounds = graph.Bounds; + + graph.Move (0, 0); + + var y = GetAxisYPosition (graph); + + // start the x axis at left of screen (either 0 or margin) + var xStart = (int)graph.MarginLeft; + + // but if the x axis has a minmum (minimum is in graph space units) + if (Minimum.HasValue) { + + // start at the screen location of the minimum + var minimumScreenX = graph.GraphSpaceToScreen (new PointF (Minimum.Value, y)).X; + + // unless that is off the screen to the left + xStart = Math.Max (xStart, minimumScreenX); + } + + for (int i = xStart; i < bounds.Width; i++) { + + DrawAxisLine (graph, i, y); + } + } + + + /// + /// Draws a horizontal axis line at the given , + /// screen coordinates + /// + /// + /// + /// + protected override void DrawAxisLine (GraphView graph, int x, int y) + { + graph.Move (x, y); + Application.Driver.AddRune (Application.Driver.HLine); + } + + /// + /// Draws the horizontal x axis labels and ticks + /// + public override void DrawAxisLabels (GraphView graph) + { + if (!Visible || Increment == 0) { + return; + } + + var bounds = graph.Bounds; + + var labels = GetLabels (graph, bounds); + + foreach (var label in labels) { + DrawAxisLabel (graph, label.ScreenLocation, label.Text); + } + + // if there is a title + if (!string.IsNullOrWhiteSpace (Text)) { + + string toRender = Text; + + // if label is too long + if (toRender.Length > graph.Bounds.Width) { + toRender = toRender.Substring (0, graph.Bounds.Width); + } + + graph.Move (graph.Bounds.Width / 2 - (toRender.Length / 2), graph.Bounds.Height - 1); + Application.Driver.AddStr (toRender); + } + } + + /// + /// Draws the given on the axis at x . + /// For the screen y position use + /// + /// Graph being drawn onto + /// Number of screen columns along the axis to take before rendering + /// Text to render under the axis tick + public override void DrawAxisLabel (GraphView graph, int screenPosition, string text) + { + var driver = Application.Driver; + var y = GetAxisYPosition (graph); + + graph.Move (screenPosition, y); + + // draw the tick on the axis + driver.AddRune (driver.TopTee); + + // and the label text + if (!string.IsNullOrWhiteSpace (text)) { + + // center the label but don't draw it outside bounds of the graph + int drawAtX = Math.Max (0, screenPosition - (text.Length / 2)); + string toRender = text; + + // this is how much space is left + int xSpaceAvailable = graph.Bounds.Width - drawAtX; + + // There is no space for the label at all! + if (xSpaceAvailable <= 0) { + return; + } + + // if we are close to right side of graph, don't overspill + if (toRender.Length > xSpaceAvailable) { + toRender = toRender.Substring (0, xSpaceAvailable); + } + + graph.Move (drawAtX, Math.Min (y + 1, graph.Bounds.Height - 1)); + driver.AddStr (toRender); + } + } + + private IEnumerable GetLabels (GraphView graph, Rect bounds) + { + // if no labels + if (Increment == 0) { + yield break; + } + + int labels = 0; + int y = GetAxisYPosition (graph); + + var start = graph.ScreenToGraphSpace (0, y); + var end = graph.ScreenToGraphSpace (bounds.Width, y); + + // don't draw labels below the minimum + if (Minimum.HasValue) { + start.X = Math.Max (start.X, Minimum.Value); + } + + var current = start; + + while (current.X < end.X) { + + int screenX = graph.GraphSpaceToScreen (new PointF (current.X, current.Y)).X; + + // Ensure the axis point does not draw into the margin + if (screenX >= graph.MarginLeft) { + // The increment we will render (normally a top T unicode symbol) + var toRender = new AxisIncrementToRender (Orientation, screenX, current.X); + + // Not every increment has to have a label + if (ShowLabelsEvery != 0) { + + // if this increment does also needs a label + if (labels++ % ShowLabelsEvery == 0) { + toRender.Text = LabelGetter (toRender); + }; + } + + // Label or no label definetly render it + yield return toRender; + } + + current.X += Increment; + } + } + /// + /// Returns the Y screen position of the origin (typically 0,0) of graph space. + /// Return value is bounded by the screen i.e. the axis is always rendered even + /// if the origin is offscreen. + /// + /// + public int GetAxisYPosition (GraphView graph) + { + // find the origin of the graph in screen space (this allows for 'crosshair' style + // graphs where positive and negative numbers visible + var origin = graph.GraphSpaceToScreen (new PointF (0, 0)); + + // float the X axis so that it accurately represents the origin of the graph + // but anchor it to top/bottom if the origin is offscreen + return Math.Min (Math.Max (0, origin.Y), graph.Bounds.Height - ((int)graph.MarginBottom + 1)); + } + } + + /// + /// The vertical (i.e. Y axis) of a + /// + public class VerticalAxis : Axis { + + + /// + /// Creates a new axis + /// + public VerticalAxis () : base (Orientation.Vertical) + { + } + + /// + /// Draws the vertical axis line + /// + /// + public override void DrawAxisLine (GraphView graph) + { + if (!Visible) { + return; + } + Rect bounds = graph.Bounds; + + var x = GetAxisXPosition (graph); + + var yEnd = GetAxisYEnd (graph); + + // don't draw down further than the control bounds + yEnd = Math.Min (yEnd, bounds.Height - (int)graph.MarginBottom); + + // Draw solid line + for (int i = 0; i < yEnd; i++) { + + DrawAxisLine (graph, x, i); + } + } + + /// + /// Draws a vertical axis line at the given , + /// screen coordinates + /// + /// + /// + /// + protected override void DrawAxisLine (GraphView graph, int x, int y) + { + graph.Move (x, y); + Application.Driver.AddRune (Application.Driver.VLine); + } + + private int GetAxisYEnd (GraphView graph) + { + // draw down the screen (0 is top of screen) + // end at the bottom of the screen + + //unless there is a minimum + if (Minimum.HasValue) { + return graph.GraphSpaceToScreen (new PointF (0, Minimum.Value)).Y; + } + + return graph.Bounds.Height; + } + + + /// + /// Draws axis markers and labels + /// + /// + public override void DrawAxisLabels (GraphView graph) + { + if (!Visible || Increment == 0) { + return; + } + + var bounds = graph.Bounds; + var labels = GetLabels (graph, bounds); + + foreach (var label in labels) { + + DrawAxisLabel (graph, label.ScreenLocation, label.Text); + } + + // if there is a title + if (!string.IsNullOrWhiteSpace (Text)) { + + string toRender = Text; + + // if label is too long + if (toRender.Length > graph.Bounds.Height) { + toRender = toRender.Substring (0, graph.Bounds.Height); + } + + // Draw it 1 letter at a time vertically down row 0 of the control + int startDrawingAtY = graph.Bounds.Height / 2 - (toRender.Length / 2); + + for (int i = 0; i < toRender.Length; i++) { + + graph.Move (0, startDrawingAtY + i); + Application.Driver.AddRune (toRender [i]); + } + + } + } + + private IEnumerable GetLabels (GraphView graph, Rect bounds) + { + // if no labels + if (Increment == 0) { + yield break; + } + + int labels = 0; + int x = GetAxisXPosition (graph); + + // remember screen space is top down so the lowest graph + // space value is at the bottom of the screen + var start = graph.ScreenToGraphSpace (x, bounds.Height - 1); + var end = graph.ScreenToGraphSpace (x, 0); + + // don't draw labels below the minimum + if (Minimum.HasValue) { + start.Y = Math.Max (start.Y, Minimum.Value); + } + + var current = start; + var dontDrawBelowScreenY = bounds.Height - graph.MarginBottom; + + while (current.Y < end.Y) { + + int screenY = graph.GraphSpaceToScreen (new PointF (current.X, current.Y)).Y; + + // if the axis label is above the bottom margin (screen y starts at 0 at the top) + if (screenY < dontDrawBelowScreenY) { + // Create the axis symbol + var toRender = new AxisIncrementToRender (Orientation, screenY, current.Y); + + // and the label (if we are due one) + if (ShowLabelsEvery != 0) { + + // if this increment also needs a label + if (labels++ % ShowLabelsEvery == 0) { + toRender.Text = LabelGetter (toRender); + }; + } + + // draw the axis symbol (and label if it has one) + yield return toRender; + } + + current.Y += Increment; + } + } + + /// + /// Draws the given on the axis at y . + /// For the screen x position use + /// + /// Graph being drawn onto + /// Number of rows from the top of the screen (i.e. down the axis) before rendering + /// Text to render to the left of the axis tick. Ensure to + /// set or sufficient that it is visible + public override void DrawAxisLabel (GraphView graph, int screenPosition, string text) + { + var x = GetAxisXPosition (graph); + var labelThickness = text.Length; + + graph.Move (x, screenPosition); + + // draw the tick on the axis + Application.Driver.AddRune (Application.Driver.RightTee); + + // and the label text + if (!string.IsNullOrWhiteSpace (text)) { + graph.Move (Math.Max (0, x - labelThickness), screenPosition); + Application.Driver.AddStr (text); + } + } + + /// + /// Returns the X screen position of the origin (typically 0,0) of graph space. + /// Return value is bounded by the screen i.e. the axis is always rendered even + /// if the origin is offscreen. + /// + /// + public int GetAxisXPosition (GraphView graph) + { + // find the origin of the graph in screen space (this allows for 'crosshair' style + // graphs where positive and negative numbers visible + var origin = graph.GraphSpaceToScreen (new PointF (0, 0)); + + // float the Y axis so that it accurately represents the origin of the graph + // but anchor it to left/right if the origin is offscreen + return Math.Min (Math.Max ((int)graph.MarginLeft, origin.X), graph.Bounds.Width - 1); + } + } + + + /// + /// A location on an axis of a that may + /// or may not have a label associated with it + /// + public class AxisIncrementToRender { + + /// + /// Direction of the parent axis + /// + public Orientation Orientation { get; } + + /// + /// The screen location (X or Y depending on ) that the + /// increment will be rendered at + /// + public int ScreenLocation { get; } + + /// + /// The value at this position on the axis in graph space + /// + public float Value { get; } + + private string _text = ""; + + /// + /// The text (if any) that should be displayed at this axis increment + /// + /// + internal string Text { + get => _text; + set { _text = value ?? ""; } + } + + /// + /// Describe a new section of an axis that requires an axis increment + /// symbol and/or label + /// + /// + /// + /// + public AxisIncrementToRender (Orientation orientation, int screen, float value) + { + Orientation = orientation; + ScreenLocation = screen; + Value = value; + } + } + + /// + /// Delegate for custom formatting of axis labels. Determines what should be displayed at a given label + /// + /// The axis increment to which the label is attached + /// + public delegate string LabelGetterDelegate (AxisIncrementToRender toRender); + +} diff --git a/Terminal.Gui/Core/Graphs/GraphCellToRender.cs b/Terminal.Gui/Core/Graphs/GraphCellToRender.cs new file mode 100644 index 000000000..402fafef3 --- /dev/null +++ b/Terminal.Gui/Core/Graphs/GraphCellToRender.cs @@ -0,0 +1,45 @@ +using System; + +namespace Terminal.Gui.Graphs { + /// + /// Describes how to render a single row/column of a based + /// on the value(s) in at that location + /// + public class GraphCellToRender { + + /// + /// The character to render in the console + /// + public Rune Rune { get; set; } + + /// + /// Optional color to render the with + /// + public Attribute? Color { get; set; } + + /// + /// Creates instance and sets with default graph coloring + /// + /// + public GraphCellToRender (Rune rune) + { + Rune = rune; + } + /// + /// Creates instance and sets with custom graph coloring + /// + /// + /// + public GraphCellToRender (Rune rune, Attribute color) : this (rune) + { + Color = color; + } + /// + /// Creates instance and sets and (or default if null) + /// + public GraphCellToRender (Rune rune, Attribute? color) : this (rune) + { + Color = color; + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Core/Graphs/Orientation.cs b/Terminal.Gui/Core/Graphs/Orientation.cs new file mode 100644 index 000000000..1b7d34a02 --- /dev/null +++ b/Terminal.Gui/Core/Graphs/Orientation.cs @@ -0,0 +1,17 @@ +namespace Terminal.Gui.Graphs { + /// + /// Direction of an element (horizontal or vertical) + /// + public enum Orientation { + + /// + /// Left to right + /// + Horizontal, + + /// + /// Bottom to top + /// + Vertical + } +} \ No newline at end of file diff --git a/Terminal.Gui/Core/Graphs/Series.cs b/Terminal.Gui/Core/Graphs/Series.cs new file mode 100644 index 000000000..48b66e37d --- /dev/null +++ b/Terminal.Gui/Core/Graphs/Series.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Terminal.Gui.Graphs { + /// + /// Describes a series of data that can be rendered into a > + /// + public interface ISeries { + + /// + /// Draws the section of a series into the + /// view + /// + /// Graph series is to be drawn onto + /// Visible area of the graph in Console Screen units (excluding margins) + /// Visible area of the graph in Graph space units + void DrawSeries (GraphView graph, Rect drawBounds, RectangleF graphBounds); + } + + + /// + /// Series composed of any number of discrete data points + /// + public class ScatterSeries : ISeries { + /// + /// Collection of each discrete point in the series + /// + /// + public List Points { get; set; } = new List (); + + /// + /// The color and character that will be rendered in the console + /// when there are point(s) in the corresponding graph space. + /// Defaults to uncolored 'x' + /// + public GraphCellToRender Fill { get; set; } = new GraphCellToRender ('x'); + + /// + /// Draws all points directly onto the graph + /// + public void DrawSeries (GraphView graph, Rect drawBounds, RectangleF graphBounds) + { + if (Fill.Color.HasValue) { + Application.Driver.SetAttribute (Fill.Color.Value); + } + + foreach (var p in Points.Where (p => graphBounds.Contains (p))) { + + var screenPoint = graph.GraphSpaceToScreen (p); + graph.AddRune (screenPoint.X, screenPoint.Y, Fill.Rune); + } + + } + + } + + + /// + /// Collection of in which bars are clustered by category + /// + public class MultiBarSeries : ISeries { + + BarSeries [] subSeries; + + /// + /// Sub collections. Each series contains the bars for a different category. Thus + /// SubSeries[0].Bars[0] is the first bar on the axis and SubSeries[1].Bars[0] is the + /// second etc + /// + public IReadOnlyCollection SubSeries { get => new ReadOnlyCollection (subSeries); } + + /// + /// The number of units of graph space between bars. Should be + /// less than + /// + public float Spacing { get; } + + /// + /// Creates a new series of clustered bars. + /// + /// Each category has this many bars + /// How far appart to put each category (in graph space) + /// How much spacing between bars in a category (should be less than /) + /// Array of colors that define bar color in each category. Length must match + public MultiBarSeries (int numberOfBarsPerCategory, float barsEvery, float spacing, Attribute [] colors = null) + { + subSeries = new BarSeries [numberOfBarsPerCategory]; + + if (colors != null && colors.Length != numberOfBarsPerCategory) { + throw new ArgumentException ("Number of colors must match the number of bars", nameof (numberOfBarsPerCategory)); + } + + + for (int i = 0; i < numberOfBarsPerCategory; i++) { + subSeries [i] = new BarSeries (); + subSeries [i].BarEvery = barsEvery; + subSeries [i].Offset = i * spacing; + + // Only draw labels for the first bar in each category + subSeries [i].DrawLabels = i == 0; + + if (colors != null) { + subSeries [i].OverrideBarColor = colors [i]; + } + } + Spacing = spacing; + } + + /// + /// Adds a new cluster of bars + /// + /// + /// + /// Values for each bar in category, must match the number of bars per category + public void AddBars (string label, Rune fill, params float [] values) + { + if (values.Length != subSeries.Length) { + throw new ArgumentException ("Number of values must match the number of bars per category", nameof (values)); + } + + for (int i = 0; i < values.Length; i++) { + subSeries [i].Bars.Add (new BarSeries.Bar (label, + new GraphCellToRender (fill), values [i])); + } + } + + /// + /// Draws all + /// + /// + /// + /// + public void DrawSeries (GraphView graph, Rect drawBounds, RectangleF graphBounds) + { + foreach (var bar in subSeries) { + bar.DrawSeries (graph, drawBounds, graphBounds); + } + + } + } + + /// + /// Series of bars positioned at regular intervals + /// + public class BarSeries : ISeries { + + /// + /// Ordered collection of graph bars to position along axis + /// + public List Bars { get; set; } = new List (); + + /// + /// Determines the spacing of bars along the axis. Defaults to 1 i.e. + /// every 1 unit of graph space a bar is rendered. Note that you should + /// also consider when changing this. + /// + public float BarEvery { get; set; } = 1; + + /// + /// Direction bars protrude from the corresponding axis. + /// Defaults to vertical + /// + public Orientation Orientation { get; set; } = Orientation.Vertical; + + /// + /// The number of units of graph space along the axis before rendering the first bar + /// (and subsequent bars - see ). Defaults to 0 + /// + public float Offset { get; set; } = 0; + + /// + /// Overrides the with a fixed color + /// + public Attribute? OverrideBarColor { get; set; } + + /// + /// True to draw along the axis under the bar. Defaults + /// to true. + /// + public bool DrawLabels { get; set; } = true; + + /// + /// Applies any color overriding + /// + /// + /// + protected virtual GraphCellToRender AdjustColor (GraphCellToRender graphCellToRender) + { + if (OverrideBarColor.HasValue) { + graphCellToRender.Color = OverrideBarColor; + } + + return graphCellToRender; + } + + /// + /// Draws bars that are currently in the + /// + /// + /// Screen area of the graph excluding margins + /// Graph space area that should be drawn into + public virtual void DrawSeries (GraphView graph, Rect drawBounds, RectangleF graphBounds) + { + for (int i = 0; i < Bars.Count; i++) { + + float xStart = Orientation == Orientation.Horizontal ? 0 : Offset + ((i + 1) * BarEvery); + float yStart = Orientation == Orientation.Horizontal ? Offset + ((i + 1) * BarEvery) : 0; + + float endX = Orientation == Orientation.Horizontal ? Bars [i].Value : xStart; + float endY = Orientation == Orientation.Horizontal ? yStart : Bars [i].Value; + + // translate to screen positions + var screenStart = graph.GraphSpaceToScreen (new PointF (xStart, yStart)); + var screenEnd = graph.GraphSpaceToScreen (new PointF (endX, endY)); + + // Start the bar from wherever the axis is + if (Orientation == Orientation.Horizontal) { + + screenStart.X = graph.AxisY.GetAxisXPosition (graph); + + // dont draw bar off the right of the control + screenEnd.X = Math.Min (graph.Bounds.Width - 1, screenEnd.X); + + // if bar is off the screen + if (screenStart.Y < 0 || screenStart.Y > drawBounds.Height - graph.MarginBottom) { + continue; + } + } else { + + // Start the axis + screenStart.Y = graph.AxisX.GetAxisYPosition (graph); + + // dont draw bar up above top of control + screenEnd.Y = Math.Max (0, screenEnd.Y); + + // if bar is off the screen + if (screenStart.X < graph.MarginLeft || screenStart.X > graph.MarginLeft + drawBounds.Width - 1) { + continue; + } + } + + // draw the bar unless it has no height + if (Bars [i].Value != 0) { + DrawBarLine (graph, screenStart, screenEnd, Bars [i]); + } + + // If we are drawing labels and the bar has one + if (DrawLabels && !string.IsNullOrWhiteSpace (Bars [i].Text)) { + + // Add the label to the relevant axis + if (Orientation == Orientation.Horizontal) { + + graph.AxisY.DrawAxisLabel (graph, screenStart.Y, Bars [i].Text); + } else if (Orientation == Orientation.Vertical) { + + graph.AxisX.DrawAxisLabel (graph, screenStart.X, Bars [i].Text); + } + } + } + + } + + /// + /// Override to do custom drawing of the bar e.g. to apply varying color or changing the fill + /// symbol mid bar. + /// + /// + /// Screen position of the start of the bar + /// Screen position of the end of the bar + /// The Bar that occupies this space and is being drawn + protected virtual void DrawBarLine (GraphView graph, Point start, Point end, Bar beingDrawn) + { + var adjusted = AdjustColor (beingDrawn.Fill); + + if (adjusted.Color.HasValue) { + Application.Driver.SetAttribute (adjusted.Color.Value); + } + + graph.DrawLine (start, end, adjusted.Rune); + + graph.SetDriverColorToGraphColor (); + } + + /// + /// A single bar in a + /// + public class Bar { + + /// + /// Optional text that describes the bar. This will be rendered on the corresponding + /// unless is false + /// + public string Text { get; set; } + + /// + /// The color and character that will be rendered in the console + /// when the bar extends over it + /// + public GraphCellToRender Fill { get; set; } + + /// + /// The value in graph space X/Y (depending on ) to which the bar extends. + /// + public float Value { get; } + + /// + /// Creates a new instance of a single bar rendered in the given that extends + /// out graph space units in the default + /// + /// + /// + /// + public Bar (string text, GraphCellToRender fill, float value) + { + Text = text; + Fill = fill; + Value = value; + } + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/Types/PointF.cs b/Terminal.Gui/Types/PointF.cs new file mode 100644 index 000000000..be874231b --- /dev/null +++ b/Terminal.Gui/Types/PointF.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// Copied from: https://github.com/dotnet/corefx/tree/master/src/System.Drawing.Primitives/src/System/Drawing + +using System; +using System.ComponentModel; + +namespace Terminal.Gui { + /// + /// Represents an ordered pair of x and y coordinates that define a point in a two-dimensional plane. + /// + public struct PointF : IEquatable { + /// + /// Creates a new instance of the class with member data left uninitialized. + /// + public static readonly PointF Empty; + private float x; // Do not rename (binary serialization) + private float y; // Do not rename (binary serialization) + + /// + /// Initializes a new instance of the class with the specified coordinates. + /// + public PointF (float x, float y) + { + this.x = x; + this.y = y; + } + + /// + /// Gets a value indicating whether this is empty. + /// + [Browsable (false)] + public bool IsEmpty => x == 0f && y == 0f; + + /// + /// Gets the x-coordinate of this . + /// + public float X { + get => x; + set => x = value; + } + + /// + /// Gets the y-coordinate of this . + /// + public float Y { + get => y; + set => y = value; + } + + /// + /// Translates a by a given . + /// + public static PointF operator + (PointF pt, Size sz) => Add (pt, sz); + + /// + /// Translates a by the negative of a given . + /// + public static PointF operator - (PointF pt, Size sz) => Subtract (pt, sz); + + /// + /// Translates a by a given . + /// + public static PointF operator + (PointF pt, SizeF sz) => Add (pt, sz); + + /// + /// Translates a by the negative of a given . + /// + public static PointF operator - (PointF pt, SizeF sz) => Subtract (pt, sz); + + /// + /// Compares two objects. The result specifies whether the values of the + /// and properties of the two + /// objects are equal. + /// + public static bool operator == (PointF left, PointF right) => left.X == right.X && left.Y == right.Y; + + /// + /// Compares two objects. The result specifies whether the values of the + /// or properties of the two + /// objects are unequal. + /// + public static bool operator != (PointF left, PointF right) => !(left == right); + + /// + /// Translates a by a given . + /// + public static PointF Add (PointF pt, Size sz) => new PointF (pt.X + sz.Width, pt.Y + sz.Height); + + /// + /// Translates a by the negative of a given . + /// + public static PointF Subtract (PointF pt, Size sz) => new PointF (pt.X - sz.Width, pt.Y - sz.Height); + + /// + /// Translates a by a given . + /// + public static PointF Add (PointF pt, SizeF sz) => new PointF (pt.X + sz.Width, pt.Y + sz.Height); + + /// + /// Translates a by the negative of a given . + /// + public static PointF Subtract (PointF pt, SizeF sz) => new PointF (pt.X - sz.Width, pt.Y - sz.Height); + + + /// + /// Compares two objects. The result specifies whether the values of the + /// and properties of the two + /// objects are equal. + /// + public override bool Equals (object obj) => obj is PointF && Equals ((PointF)obj); + + + /// + /// Compares two objects. The result specifies whether the values of the + /// and properties of the two + /// objects are equal. + /// + public bool Equals (PointF other) => this == other; + + /// + /// Generates a hashcode from the X and Y components + /// + /// + public override int GetHashCode () + { + return X.GetHashCode() ^ Y.GetHashCode (); + } + + /// + /// Returns a string including the X and Y values + /// + /// + public override string ToString () => "{X=" + x.ToString () + ", Y=" + y.ToString () + "}"; + } +} diff --git a/Terminal.Gui/Types/RectangleF.cs b/Terminal.Gui/Types/RectangleF.cs new file mode 100644 index 000000000..8fa058518 --- /dev/null +++ b/Terminal.Gui/Types/RectangleF.cs @@ -0,0 +1,303 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// Copied from https://github.com/dotnet/corefx/tree/master/src/System.Drawing.Primitives/src/System/Drawing + +using System; +using System.ComponentModel; + +namespace Terminal.Gui { + /// + /// Stores the location and size of a rectangular region. + /// + public struct RectangleF : IEquatable { + /// + /// Initializes a new instance of the class. + /// + public static readonly RectangleF Empty; + + private float x; // Do not rename (binary serialization) + private float y; // Do not rename (binary serialization) + private float width; // Do not rename (binary serialization) + private float height; // Do not rename (binary serialization) + + /// + /// Initializes a new instance of the class with the specified location + /// and size. + /// + public RectangleF (float x, float y, float width, float height) + { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + /// + /// Initializes a new instance of the class with the specified location + /// and size. + /// + public RectangleF (PointF location, SizeF size) + { + x = location.X; + y = location.Y; + width = size.Width; + height = size.Height; + } + + /// + /// Creates a new with the specified location and size. + /// + public static RectangleF FromLTRB (float left, float top, float right, float bottom) => + new RectangleF (left, top, right - left, bottom - top); + + /// + /// Gets or sets the coordinates of the upper-left corner of the rectangular region represented by this + /// . + /// + [Browsable (false)] + public PointF Location { + get => new PointF (X, Y); + set { + X = value.X; + Y = value.Y; + } + } + + /// + /// Gets or sets the size of this . + /// + [Browsable (false)] + public SizeF Size { + get => new SizeF (Width, Height); + set { + Width = value.Width; + Height = value.Height; + } + } + + /// + /// Gets or sets the x-coordinate of the upper-left corner of the rectangular region defined by this + /// . + /// + public float X { + get => x; + set => x = value; + } + + /// + /// Gets or sets the y-coordinate of the upper-left corner of the rectangular region defined by this + /// . + /// + public float Y { + get => y; + set => y = value; + } + + /// + /// Gets or sets the width of the rectangular region defined by this . + /// + public float Width { + get => width; + set => width = value; + } + + /// + /// Gets or sets the height of the rectangular region defined by this . + /// + public float Height { + get => height; + set => height = value; + } + + /// + /// Gets the x-coordinate of the upper-left corner of the rectangular region defined by this + /// . + /// + [Browsable (false)] + public float Left => X; + + /// + /// Gets the y-coordinate of the upper-left corner of the rectangular region defined by this + /// . + /// + [Browsable (false)] + public float Top => Y; + + /// + /// Gets the x-coordinate of the lower-right corner of the rectangular region defined by this + /// . + /// + [Browsable (false)] + public float Right => X + Width; + + /// + /// Gets the y-coordinate of the lower-right corner of the rectangular region defined by this + /// . + /// + [Browsable (false)] + public float Bottom => Y + Height; + + /// + /// Tests whether this has a or a of 0. + /// + [Browsable (false)] + public bool IsEmpty => (Width <= 0) || (Height <= 0); + + /// + /// Tests whether is a with the same location and + /// size of this . + /// + public override bool Equals (object obj) => obj is RectangleF && Equals ((RectangleF)obj); + + /// + /// Returns true if two objects have equal location and size. + /// + /// + /// + public bool Equals (RectangleF other) => this == other; + + /// + /// Tests whether two objects have equal location and size. + /// + public static bool operator == (RectangleF left, RectangleF right) => + left.X == right.X && left.Y == right.Y && left.Width == right.Width && left.Height == right.Height; + + /// + /// Tests whether two objects differ in location or size. + /// + public static bool operator != (RectangleF left, RectangleF right) => !(left == right); + + /// + /// Determines if the specified point is contained within the rectangular region defined by this + /// . + /// + public bool Contains (float x, float y) => X <= x && x < X + Width && Y <= y && y < Y + Height; + + /// + /// Determines if the specified point is contained within the rectangular region defined by this + /// . + /// + public bool Contains (PointF pt) => Contains (pt.X, pt.Y); + + /// + /// Determines if the rectangular region represented by is entirely contained within + /// the rectangular region represented by this . + /// + public bool Contains (RectangleF rect) => + (X <= rect.X) && (rect.X + rect.Width <= X + Width) && (Y <= rect.Y) && (rect.Y + rect.Height <= Y + Height); + + /// + /// Gets the hash code for this . + /// + public override int GetHashCode () + { + return (Height.GetHashCode () + Width.GetHashCode ()) ^ X.GetHashCode () + Y.GetHashCode (); + } + + /// + /// Inflates this by the specified amount. + /// + public void Inflate (float x, float y) + { + X -= x; + Y -= y; + Width += 2 * x; + Height += 2 * y; + } + + /// + /// Inflates this by the specified amount. + /// + public void Inflate (SizeF size) => Inflate (size.Width, size.Height); + + /// + /// Creates a that is inflated by the specified amount. + /// + public static RectangleF Inflate (RectangleF rect, float x, float y) + { + RectangleF r = rect; + r.Inflate (x, y); + return r; + } + + /// + /// Creates a Rectangle that represents the intersection between this Rectangle and rect. + /// + public void Intersect (RectangleF rect) + { + RectangleF result = Intersect (rect, this); + + X = result.X; + Y = result.Y; + Width = result.Width; + Height = result.Height; + } + + /// + /// Creates a rectangle that represents the intersection between a and b. If there is no intersection, an + /// empty rectangle is returned. + /// + public static RectangleF Intersect (RectangleF a, RectangleF b) + { + float x1 = Math.Max (a.X, b.X); + float x2 = Math.Min (a.X + a.Width, b.X + b.Width); + float y1 = Math.Max (a.Y, b.Y); + float y2 = Math.Min (a.Y + a.Height, b.Y + b.Height); + + if (x2 >= x1 && y2 >= y1) { + return new RectangleF (x1, y1, x2 - x1, y2 - y1); + } + + return Empty; + } + + /// + /// Determines if this rectangle intersects with rect. + /// + public bool IntersectsWith (RectangleF rect) => + (rect.X < X + Width) && (X < rect.X + rect.Width) && (rect.Y < Y + Height) && (Y < rect.Y + rect.Height); + + /// + /// Creates a rectangle that represents the union between a and b. + /// + public static RectangleF Union (RectangleF a, RectangleF b) + { + float x1 = Math.Min (a.X, b.X); + float x2 = Math.Max (a.X + a.Width, b.X + b.Width); + float y1 = Math.Min (a.Y, b.Y); + float y2 = Math.Max (a.Y + a.Height, b.Y + b.Height); + + return new RectangleF (x1, y1, x2 - x1, y2 - y1); + } + + /// + /// Adjusts the location of this rectangle by the specified amount. + /// + public void Offset (PointF pos) => Offset (pos.X, pos.Y); + + /// + /// Adjusts the location of this rectangle by the specified amount. + /// + public void Offset (float x, float y) + { + X += x; + Y += y; + } + + /// + /// Converts the specified to a + /// . + /// + public static implicit operator RectangleF (Rect r) => new RectangleF (r.X, r.Y, r.Width, r.Height); + + /// + /// Converts the and + /// of this to a human-readable string. + /// + public override string ToString () => + "{X=" + X.ToString () + ",Y=" + Y.ToString () + + ",Width=" + Width.ToString () + ",Height=" + Height.ToString () + "}"; + } +} diff --git a/Terminal.Gui/Types/SizeF.cs b/Terminal.Gui/Types/SizeF.cs new file mode 100644 index 000000000..226dadc0d --- /dev/null +++ b/Terminal.Gui/Types/SizeF.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// Copied from: https://github.com/dotnet/corefx/tree/master/src/System.Drawing.Primitives/src/System/Drawing + +using System; +using System.ComponentModel; + +namespace Terminal.Gui { + /// + /// Represents the size of a rectangular region with an ordered pair of width and height. + /// + public struct SizeF : IEquatable { + /// + /// Initializes a new instance of the class. + /// + public static readonly SizeF Empty; + private float width; // Do not rename (binary serialization) + private float height; // Do not rename (binary serialization) + + /// + /// Initializes a new instance of the class from the specified + /// existing . + /// + public SizeF (SizeF size) + { + width = size.width; + height = size.height; + } + + /// + /// Initializes a new instance of the class from the specified + /// . + /// + public SizeF (PointF pt) + { + width = pt.X; + height = pt.Y; + } + + /// + /// Initializes a new instance of the class from the specified dimensions. + /// + public SizeF (float width, float height) + { + this.width = width; + this.height = height; + } + + /// + /// Performs vector addition of two objects. + /// + public static SizeF operator + (SizeF sz1, SizeF sz2) => Add (sz1, sz2); + + /// + /// Contracts a by another + /// + public static SizeF operator - (SizeF sz1, SizeF sz2) => Subtract (sz1, sz2); + + /// + /// Multiplies by a producing . + /// + /// Multiplier of type . + /// Multiplicand of type . + /// Product of type . + public static SizeF operator * (float left, SizeF right) => Multiply (right, left); + + /// + /// Multiplies by a producing . + /// + /// Multiplicand of type . + /// Multiplier of type . + /// Product of type . + public static SizeF operator * (SizeF left, float right) => Multiply (left, right); + + /// + /// Divides by a producing . + /// + /// Dividend of type . + /// Divisor of type . + /// Result of type . + public static SizeF operator / (SizeF left, float right) + => new SizeF (left.width / right, left.height / right); + + /// + /// Tests whether two objects are identical. + /// + public static bool operator == (SizeF sz1, SizeF sz2) => sz1.Width == sz2.Width && sz1.Height == sz2.Height; + + /// + /// Tests whether two objects are different. + /// + public static bool operator != (SizeF sz1, SizeF sz2) => !(sz1 == sz2); + + /// + /// Converts the specified to a . + /// + public static explicit operator PointF (SizeF size) => new PointF (size.Width, size.Height); + + /// + /// Tests whether this has zero width and height. + /// + [Browsable (false)] + public bool IsEmpty => width == 0 && height == 0; + + /// + /// Represents the horizontal component of this . + /// + public float Width { + get => width; + set => width = value; + } + + /// + /// Represents the vertical component of this . + /// + public float Height { + get => height; + set => height = value; + } + + /// + /// Performs vector addition of two objects. + /// + public static SizeF Add (SizeF sz1, SizeF sz2) => new SizeF (sz1.Width + sz2.Width, sz1.Height + sz2.Height); + + /// + /// Contracts a by another . + /// + public static SizeF Subtract (SizeF sz1, SizeF sz2) => new SizeF (sz1.Width - sz2.Width, sz1.Height - sz2.Height); + + /// + /// Tests to see whether the specified object is a with the same dimensions + /// as this . + /// + public override bool Equals (object obj) => obj is SizeF && Equals ((SizeF)obj); + + + /// + /// Tests whether two objects are identical. + /// + public bool Equals (SizeF other) => this == other; + + /// + /// Generates a hashcode from the width and height + /// + /// + public override int GetHashCode () + { + return width.GetHashCode() ^ height.GetHashCode (); + } + + /// + /// Creates a human-readable string that represents this . + /// + public override string ToString () => "{Width=" + width.ToString () + ", Height=" + height.ToString () + "}"; + + /// + /// Multiplies by a producing . + /// + /// Multiplicand of type . + /// Multiplier of type . + /// Product of type SizeF. + private static SizeF Multiply (SizeF size, float multiplier) => + new SizeF (size.width * multiplier, size.height * multiplier); + } +} diff --git a/Terminal.Gui/Views/GraphView.cs b/Terminal.Gui/Views/GraphView.cs new file mode 100644 index 000000000..f80c20384 --- /dev/null +++ b/Terminal.Gui/Views/GraphView.cs @@ -0,0 +1,318 @@ +using NStack; +using System; +using System.Collections.Generic; +using System.Linq; +using Terminal.Gui.Graphs; + +namespace Terminal.Gui { + + /// + /// Control for rendering graphs (bar, scatter etc) + /// + public class GraphView : View { + + /// + /// Horizontal axis + /// + /// + public HorizontalAxis AxisX { get; set; } + + /// + /// Vertical axis + /// + /// + public VerticalAxis AxisY { get; set; } + + /// + /// Collection of data series that are rendered in the graph + /// + public List Series { get; } = new List (); + + + /// + /// Elements drawn into graph after series have been drawn e.g. Legends etc + /// + public List Annotations { get; } = new List (); + + /// + /// Amount of space to leave on left of control. Graph content () + /// will not be rendered in margins but axis labels may be + /// + public uint MarginLeft { get; set; } + + /// + /// Amount of space to leave on bottom of control. Graph content () + /// will not be rendered in margins but axis labels may be + /// + public uint MarginBottom { get; set; } + + /// + /// The graph space position of the bottom left of the control. + /// Changing this scrolls the viewport around in the graph + /// + /// + public PointF ScrollOffset { get; set; } = new PointF (0, 0); + + /// + /// 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 PointF (1, 1); + + /// + /// The color of the background of the graph and axis/labels + /// + public Attribute? GraphColor { get; set; } + + /// + /// Creates a new graph with a 1 to 1 graph space with absolute layout + /// + public GraphView () + { + CanFocus = true; + + AxisX = new HorizontalAxis (); + AxisY = new VerticalAxis (); + } + + /// + /// 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 (); + } + + /// + public override void Redraw (Rect bounds) + { + if(CellSize.X == 0 || CellSize.Y == 0) { + throw new Exception ($"{nameof(CellSize)} cannot be 0"); + } + + SetDriverColorToGraphColor (); + + Move (0, 0); + + // clear all old content + for (int i = 0; i < Bounds.Height; i++) { + Move (0, i); + Driver.AddStr (new string (' ', Bounds.Width)); + } + + // If there is no data do not display a graph + if (!Series.Any () && !Annotations.Any ()) { + return; + } + + // Draw 'before' annotations + foreach (var a in Annotations.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, '\u253C'); + } + + SetDriverColorToGraphColor (); + + // The drawable area of the graph (anything that isn't in the margins) + Rect drawBounds = new Rect((int)MarginLeft,0, Bounds.Width - ((int)MarginLeft), Bounds.Height - (int)MarginBottom); + RectangleF graphSpace = ScreenToGraphSpace (drawBounds); + + foreach (var s in Series) { + + s.DrawSeries (this, drawBounds, graphSpace); + + // If a series changes the graph color reset it + SetDriverColorToGraphColor (); + } + + SetDriverColorToGraphColor (); + + // Draw 'after' annotations + foreach (var a in Annotations.Where (a => !a.BeforeSeries)) { + a.Render (this); + } + + } + + /// + /// Sets the color attribute of to the + /// (if defined) or otherwise. + /// + public void SetDriverColorToGraphColor () + { + Driver.SetAttribute (GraphColor ?? ColorScheme.Normal); + } + + /// + /// Returns the section of the graph that is represented by the given + /// screen position + /// + /// + /// + /// + public RectangleF ScreenToGraphSpace (int col, int row) + { + return new RectangleF ( + ScrollOffset.X + ((col - MarginLeft) * CellSize.X), + ScrollOffset.Y + ((Bounds.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 (Rect screenArea) + { + // get position of the bottom left + var pos = ScreenToGraphSpace (screenArea.Left, screenArea.Bottom-1); + + return new RectangleF (pos.X, pos.Y, screenArea.Width * CellSize.X, screenArea.Height * CellSize.Y); + } + /// + /// Calculates the screen location for a given point in graph space. + /// Bear in mind these 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 client area of the control + 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 + (Bounds.Height - 1) - (int)MarginBottom - (int)((location.Y - ScrollOffset.Y) / CellSize.Y) + ); + } + + + + /// + public override bool ProcessKey (KeyEvent keyEvent) + { + //&& Focused == tabsBar + + if (HasFocus && CanFocus) { + switch (keyEvent.Key) { + + case Key.CursorLeft: + Scroll (-CellSize.X, 0); + return true; + case Key.CursorLeft | Key.CtrlMask: + Scroll (-CellSize.X * 5, 0); + return true; + case Key.CursorRight: + Scroll (CellSize.X, 0); + return true; + case Key.CursorRight | Key.CtrlMask: + Scroll (CellSize.X * 5, 0); + return true; + case Key.CursorDown: + Scroll (0, -CellSize.Y); + return true; + case Key.CursorDown | Key.CtrlMask: + Scroll (0, -CellSize.Y * 5); + return true; + case Key.CursorUp: + Scroll (0, CellSize.Y); + return true; + case Key.CursorUp | Key.CtrlMask: + Scroll (0, CellSize.Y * 5); + return true; + } + } + + return base.ProcessKey (keyEvent); + } + + /// + /// Scrolls the view by a given number of units in graph space. + /// See to translate this into rows/cols + /// + /// + /// + private void Scroll (float offsetX, float offsetY) + { + ScrollOffset = new PointF ( + ScrollOffset.X + offsetX, + ScrollOffset.Y + offsetY); + + SetNeedsDisplay (); + } + + + #region Bresenham's line algorithm + // https://rosettacode.org/wiki/Bitmap/Bresenham%27s_line_algorithm#C.23 + + int ipart (decimal x) { return (int)x; } + + + decimal fpart (decimal x) + { + if (x < 0) return (1 - (x - Math.Floor (x))); + return (x - Math.Floor (x)); + } + + /// + /// 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 + } +} \ No newline at end of file diff --git a/UICatalog/Scenarios/GraphViewExample.cs b/UICatalog/Scenarios/GraphViewExample.cs new file mode 100644 index 000000000..b98caacf3 --- /dev/null +++ b/UICatalog/Scenarios/GraphViewExample.cs @@ -0,0 +1,685 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Terminal.Gui; +using Terminal.Gui.Graphs; + +using Color = Terminal.Gui.Color; + +namespace UICatalog.Scenarios { + + [ScenarioMetadata (Name: "Graph View", Description: "Demos GraphView control")] + [ScenarioCategory ("Controls")] + class GraphViewExample : Scenario { + + GraphView graphView; + private TextView about; + + int currentGraph = 0; + Action [] graphs; + + public override void Setup () + { + Win.Title = this.GetName (); + Win.Y = 1; // menu + Win.Height = Dim.Fill (1); // status bar + Top.LayoutSubviews (); + + graphs = new Action [] { + ()=>SetupPeriodicTableScatterPlot(), //0 + ()=>SetupLifeExpectancyBarGraph(true), //1 + ()=>SetupLifeExpectancyBarGraph(false), //2 + ()=>SetupPopulationPyramid(), //3 + ()=>SetupLineGraph(), //4 + ()=>SetupSineWave(), //5 + ()=>SetupDisco(), //6 + ()=>MultiBarGraph() //7 + }; + + + var menu = new MenuBar (new MenuBarItem [] { + new MenuBarItem ("_File", new MenuItem [] { + new MenuItem ("Scatter _Plot", "",()=>graphs[currentGraph = 0]()), + new MenuItem ("_V Bar Graph", "", ()=>graphs[currentGraph = 1]()), + new MenuItem ("_H Bar Graph", "", ()=>graphs[currentGraph = 2]()) , + new MenuItem ("P_opulation Pyramid","",()=>graphs[currentGraph = 3]()), + new MenuItem ("_Line Graph","",()=>graphs[currentGraph = 4]()), + new MenuItem ("Sine _Wave","",()=>graphs[currentGraph = 5]()), + new MenuItem ("Silent _Disco","",()=>graphs[currentGraph = 6]()), + new MenuItem ("_Multi Bar Graph","",()=>graphs[currentGraph = 7]()), + new MenuItem ("_Quit", "", () => Quit()), + }), + new MenuBarItem ("_View", new MenuItem [] { + new MenuItem ("Zoom _In", "", () => Zoom(0.5f)), + new MenuItem ("Zoom _Out", "", () => Zoom(2f)), + }), + + }); + Top.Add (menu); + + graphView = new GraphView () { + X = 1, + Y = 1, + Width = 60, + Height = 20, + }; + + + Win.Add (graphView); + + + var frameRight = new FrameView ("About") { + X = Pos.Right (graphView) + 1, + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill (), + }; + + + frameRight.Add (about = new TextView () { + Width = Dim.Fill (), + Height = Dim.Fill () + }); + + Win.Add (frameRight); + + + var statusBar = new StatusBar (new StatusItem [] { + new StatusItem(Key.CtrlMask | Key.Q, "~^Q~ Quit", () => Quit()), + new StatusItem(Key.CtrlMask | Key.G, "~^G~ Next", ()=>graphs[currentGraph++%graphs.Length]()), + }); + Top.Add (statusBar); + } + + private void MultiBarGraph () + { + graphView.Reset (); + + about.Text = "Housing Expenditures by income thirds 1996-2003"; + + var black = Application.Driver.MakeAttribute (graphView.ColorScheme.Normal.Foreground, Color.Black); + var cyan = Application.Driver.MakeAttribute (Color.BrightCyan, Color.Black); + var magenta = Application.Driver.MakeAttribute (Color.BrightMagenta, Color.Black); + var red = Application.Driver.MakeAttribute (Color.BrightRed, Color.Black); + + graphView.GraphColor = black; + + var series = new MultiBarSeries (3, 1, 0.25f, new [] { magenta, cyan, red }); + + var stiple = Application.Driver.Stipple; + + series.AddBars ("'96", stiple, 5900, 9000, 14000); + series.AddBars ("'97", stiple, 6100, 9200, 14800); + series.AddBars ("'98", stiple, 6000, 9300, 14600); + series.AddBars ("'99", stiple, 6100, 9400, 14950); + series.AddBars ("'00", stiple, 6200, 9500, 15200); + series.AddBars ("'01", stiple, 6250, 9900, 16000); + series.AddBars ("'02", stiple, 6600, 11000, 16700); + series.AddBars ("'03", stiple, 7000, 12000, 17000); + + graphView.CellSize = new PointF (0.25f, 1000); + graphView.Series.Add (series); + graphView.SetNeedsDisplay (); + + graphView.MarginLeft = 3; + graphView.MarginBottom = 1; + + graphView.AxisY.LabelGetter = (v) => '$' + (v.Value / 1000f).ToString ("N0") + 'k'; + + // Do not show x axis labels (bars draw their own labels) + graphView.AxisX.Increment = 0; + graphView.AxisX.ShowLabelsEvery = 0; + graphView.AxisX.Minimum = 0; + + + graphView.AxisY.Minimum = 0; + + var legend = new LegendAnnotation (new Rect (graphView.Bounds.Width - 20,0, 20, 5)); + legend.AddEntry (new GraphCellToRender (stiple, series.SubSeries.ElementAt (0).OverrideBarColor), "Lower Third"); + legend.AddEntry (new GraphCellToRender (stiple, series.SubSeries.ElementAt (1).OverrideBarColor), "Middle Third"); + legend.AddEntry (new GraphCellToRender (stiple, series.SubSeries.ElementAt (2).OverrideBarColor), "Upper Third"); + graphView.Annotations.Add (legend); + } + + private void SetupLineGraph () + { + graphView.Reset (); + + about.Text = "This graph shows random points"; + + var black = Application.Driver.MakeAttribute (graphView.ColorScheme.Normal.Foreground, Color.Black); + var cyan = Application.Driver.MakeAttribute (Color.BrightCyan, Color.Black); + var magenta = Application.Driver.MakeAttribute (Color.BrightMagenta, Color.Black); + var red = Application.Driver.MakeAttribute (Color.BrightRed, Color.Black); + + graphView.GraphColor = black; + + List randomPoints = new List (); + + Random r = new Random (); + + for (int i = 0; i < 10; i++) { + randomPoints.Add (new PointF (r.Next (100), r.Next (100))); + } + + var points = new ScatterSeries () { + Points = randomPoints + }; + + var line = new PathAnnotation () { + LineColor = cyan, + Points = randomPoints.OrderBy (p => p.X).ToList (), + BeforeSeries = true, + }; + + graphView.Series.Add (points); + graphView.Annotations.Add (line); + + + randomPoints = new List (); + + for (int i = 0; i < 10; i++) { + randomPoints.Add (new PointF (r.Next (100), r.Next (100))); + } + + + var points2 = new ScatterSeries () { + Points = randomPoints, + Fill = new GraphCellToRender ('x', red) + }; + + var line2 = new PathAnnotation () { + LineColor = magenta, + Points = randomPoints.OrderBy (p => p.X).ToList (), + BeforeSeries = true, + }; + + graphView.Series.Add (points2); + graphView.Annotations.Add (line2); + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (2, 5); + + // leave space for axis labels + graphView.MarginBottom = 2; + graphView.MarginLeft = 3; + + // One axis tick/label per + graphView.AxisX.Increment = 20; + graphView.AxisX.ShowLabelsEvery = 1; + graphView.AxisX.Text = "X →"; + + graphView.AxisY.Increment = 20; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.Text = "↑Y"; + + var max = line.Points.Union (line2.Points).OrderByDescending (p => p.Y).First (); + graphView.Annotations.Add (new TextAnnotation () { Text = "(Max)", GraphPosition = new PointF (max.X + (2 * graphView.CellSize.X), max.Y) }); + + graphView.SetNeedsDisplay (); + } + + private void SetupSineWave () + { + graphView.Reset (); + + about.Text = "This graph shows a sine wave"; + + var points = new ScatterSeries (); + var line = new PathAnnotation (); + + // Draw line first so it does not draw over top of points or axis labels + line.BeforeSeries = true; + + // Generate line graph with 2,000 points + for (float x = -500; x < 500; x += 0.5f) { + points.Points.Add (new PointF (x, (float)Math.Sin (x))); + line.Points.Add (new PointF (x, (float)Math.Sin (x))); + } + + graphView.Series.Add (points); + graphView.Annotations.Add (line); + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (0.1f, 0.1f); + + // leave space for axis labels + graphView.MarginBottom = 2; + graphView.MarginLeft = 3; + + // One axis tick/label per + graphView.AxisX.Increment = 0.5f; + graphView.AxisX.ShowLabelsEvery = 2; + graphView.AxisX.Text = "X →"; + graphView.AxisX.LabelGetter = (v) => v.Value.ToString ("N2"); + + graphView.AxisY.Increment = 0.2f; + graphView.AxisY.ShowLabelsEvery = 2; + graphView.AxisY.Text = "↑Y"; + graphView.AxisY.LabelGetter = (v) => v.Value.ToString ("N2"); + + graphView.ScrollOffset = new PointF (-2.5f, -1); + + graphView.SetNeedsDisplay (); + } + /* + Country,Both,Male,Female + +"Switzerland",83.4,81.8,85.1 +"South Korea",83.3,80.3,86.1 +"Singapore",83.2,81,85.5 +"Spain",83.2,80.7,85.7 +"Cyprus",83.1,81.1,85.1 +"Australia",83,81.3,84.8 +"Italy",83,80.9,84.9 +"Norway",83,81.2,84.7 +"Israel",82.6,80.8,84.4 +"France",82.5,79.8,85.1 +"Luxembourg",82.4,80.6,84.2 +"Sweden",82.4,80.8,84 +"Iceland",82.3,80.8,83.9 +"Canada",82.2,80.4,84.1 +"New Zealand",82,80.4,83.5 +"Malta,81.9",79.9,83.8 +"Ireland",81.8,80.2,83.5 +"Netherlands",81.8,80.4,83.1 +"Germany",81.7,78.7,84.8 +"Austria",81.6,79.4,83.8 +"Finland",81.6,79.2,84 +"Portugal",81.6,78.6,84.4 +"Belgium",81.4,79.3,83.5 +"United Kingdom",81.4,79.8,83 +"Denmark",81.3,79.6,83 +"Slovenia",81.3,78.6,84.1 +"Greece",81.1,78.6,83.6 +"Kuwait",81,79.3,83.9 +"Costa Rica",80.8,78.3,83.4*/ + + private void SetupLifeExpectancyBarGraph (bool verticalBars) + { + graphView.Reset (); + + about.Text = "This graph shows the life expectancy at birth of a range of countries"; + + var softStiple = new GraphCellToRender ('\u2591'); + var mediumStiple = new GraphCellToRender ('\u2592'); + + var barSeries = new BarSeries () { + Bars = new List () { + new BarSeries.Bar ("Switzerland", softStiple, 83.4f), + new BarSeries.Bar ("South Korea", !verticalBars?mediumStiple:softStiple, 83.3f), + new BarSeries.Bar ("Singapore", softStiple, 83.2f), + new BarSeries.Bar ("Spain", !verticalBars?mediumStiple:softStiple, 83.2f), + new BarSeries.Bar ("Cyprus", softStiple, 83.1f), + new BarSeries.Bar ("Australia", !verticalBars?mediumStiple:softStiple, 83), + new BarSeries.Bar ("Italy", softStiple, 83), + new BarSeries.Bar ("Norway", !verticalBars?mediumStiple:softStiple, 83), + new BarSeries.Bar ("Israel", softStiple, 82.6f), + new BarSeries.Bar ("France", !verticalBars?mediumStiple:softStiple, 82.5f), + new BarSeries.Bar ("Luxembourg", softStiple, 82.4f), + new BarSeries.Bar ("Sweden", !verticalBars?mediumStiple:softStiple, 82.4f), + new BarSeries.Bar ("Iceland", softStiple, 82.3f), + new BarSeries.Bar ("Canada", !verticalBars?mediumStiple:softStiple, 82.2f), + new BarSeries.Bar ("New Zealand", softStiple, 82), + new BarSeries.Bar ("Malta", !verticalBars?mediumStiple:softStiple, 81.9f), + new BarSeries.Bar ("Ireland", softStiple, 81.8f) + } + }; + + graphView.Series.Add (barSeries); + + if (verticalBars) { + + barSeries.Orientation = Orientation.Vertical; + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (0.1f, 0.25f); + // No axis marks since Bar will add it's own categorical marks + graphView.AxisX.Increment = 0f; + graphView.AxisX.Text = "Country"; + graphView.AxisX.Minimum = 0; + + graphView.AxisY.Increment = 1f; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.LabelGetter = v => v.Value.ToString ("N2"); + graphView.AxisY.Minimum = 0; + graphView.AxisY.Text = "Age"; + + // leave space for axis labels and title + graphView.MarginBottom = 2; + graphView.MarginLeft = 6; + + // Start the graph at 80 years because that is where most of our data is + graphView.ScrollOffset = new PointF (0, 80); + + } else { + barSeries.Orientation = Orientation.Horizontal; + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (0.1f, 1f); + // No axis marks since Bar will add it's own categorical marks + graphView.AxisY.Increment = 0f; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.Text = "Country"; + graphView.AxisY.Minimum = 0; + + graphView.AxisX.Increment = 1f; + graphView.AxisX.ShowLabelsEvery = 1; + graphView.AxisX.LabelGetter = v => v.Value.ToString ("N2"); + graphView.AxisX.Text = "Age"; + graphView.AxisX.Minimum = 0; + + // leave space for axis labels and title + graphView.MarginBottom = 2; + graphView.MarginLeft = (uint)barSeries.Bars.Max (b => b.Text.Length) + 2; + + // Start the graph at 80 years because that is where most of our data is + graphView.ScrollOffset = new PointF (80, 0); + } + + graphView.SetNeedsDisplay (); + } + + private void SetupPopulationPyramid () + { + /* + Age,M,F +0-4,2009363,1915127 +5-9,2108550,2011016 +10-14,2022370,1933970 +15-19,1880611,1805522 +20-24,2072674,2001966 +25-29,2275138,2208929 +30-34,2361054,2345774 +35-39,2279836,2308360 +40-44,2148253,2159877 +45-49,2128343,2167778 +50-54,2281421,2353119 +55-59,2232388,2306537 +60-64,1919839,1985177 +65-69,1647391,1734370 +70-74,1624635,1763853 +75-79,1137438,1304709 +80-84,766956,969611 +85-89,438663,638892 +90-94,169952,320625 +95-99,34524,95559 +100+,3016,12818*/ + + about.Text = "This graph shows population of each age divided by gender"; + + graphView.Reset (); + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (100_000, 1); + + //center the x axis in middle of screen to show both sides + graphView.ScrollOffset = new PointF (-3_000_000, 0); + + graphView.AxisX.Text = "Number Of People"; + graphView.AxisX.Increment = 500_000; + graphView.AxisX.ShowLabelsEvery = 2; + + // use Abs to make negative axis labels positive + graphView.AxisX.LabelGetter = (v) => Math.Abs (v.Value / 1_000_000).ToString ("N2") + "M"; + + // leave space for axis labels + graphView.MarginBottom = 2; + graphView.MarginLeft = 1; + + // do not show axis titles (bars have their own categories) + graphView.AxisY.Increment = 0; + graphView.AxisY.ShowLabelsEvery = 0; + graphView.AxisY.Minimum = 0; + + var stiple = new GraphCellToRender (Application.Driver.Stipple); + + // Bars in 2 directions + + // Males (negative to make the bars go left) + var malesSeries = new BarSeries () { + Orientation = Orientation.Horizontal, + Bars = new List () + { + new BarSeries.Bar("0-4",stiple,-2009363), + new BarSeries.Bar("5-9",stiple,-2108550), + new BarSeries.Bar("10-14",stiple,-2022370), + new BarSeries.Bar("15-19",stiple,-1880611), + new BarSeries.Bar("20-24",stiple,-2072674), + new BarSeries.Bar("25-29",stiple,-2275138), + new BarSeries.Bar("30-34",stiple,-2361054), + new BarSeries.Bar("35-39",stiple,-2279836), + new BarSeries.Bar("40-44",stiple,-2148253), + new BarSeries.Bar("45-49",stiple,-2128343), + new BarSeries.Bar("50-54",stiple,-2281421), + new BarSeries.Bar("55-59",stiple,-2232388), + new BarSeries.Bar("60-64",stiple,-1919839), + new BarSeries.Bar("65-69",stiple,-1647391), + new BarSeries.Bar("70-74",stiple,-1624635), + new BarSeries.Bar("75-79",stiple,-1137438), + new BarSeries.Bar("80-84",stiple,-766956), + new BarSeries.Bar("85-89",stiple,-438663), + new BarSeries.Bar("90-94",stiple,-169952), + new BarSeries.Bar("95-99",stiple,-34524), + new BarSeries.Bar("100+",stiple,-3016) + + } + }; + graphView.Series.Add (malesSeries); + + + // Females + var femalesSeries = new BarSeries () { + Orientation = Orientation.Horizontal, + Bars = new List () + { + new BarSeries.Bar("0-4",stiple,1915127), + new BarSeries.Bar("5-9",stiple,2011016), + new BarSeries.Bar("10-14",stiple,1933970), + new BarSeries.Bar("15-19",stiple,1805522), + new BarSeries.Bar("20-24",stiple,2001966), + new BarSeries.Bar("25-29",stiple,2208929), + new BarSeries.Bar("30-34",stiple,2345774), + new BarSeries.Bar("35-39",stiple,2308360), + new BarSeries.Bar("40-44",stiple,2159877), + new BarSeries.Bar("45-49",stiple,2167778), + new BarSeries.Bar("50-54",stiple,2353119), + new BarSeries.Bar("55-59",stiple,2306537), + new BarSeries.Bar("60-64",stiple,1985177), + new BarSeries.Bar("65-69",stiple,1734370), + new BarSeries.Bar("70-74",stiple,1763853), + new BarSeries.Bar("75-79",stiple,1304709), + new BarSeries.Bar("80-84",stiple,969611), + new BarSeries.Bar("85-89",stiple,638892), + new BarSeries.Bar("90-94",stiple,320625), + new BarSeries.Bar("95-99",stiple,95559), + new BarSeries.Bar("100+",stiple,12818) + } + }; + + + var softStiple = new GraphCellToRender ('\u2591'); + var mediumStiple = new GraphCellToRender ('\u2592'); + + for (int i = 0; i < malesSeries.Bars.Count; i++) { + malesSeries.Bars [i].Fill = i % 2 == 0 ? softStiple : mediumStiple; + femalesSeries.Bars [i].Fill = i % 2 == 0 ? softStiple : mediumStiple; + } + + graphView.Series.Add (femalesSeries); + + graphView.Annotations.Add (new TextAnnotation () { Text = "M", ScreenPosition = new Terminal.Gui.Point (0, 10) }); + graphView.Annotations.Add (new TextAnnotation () { Text = "F", ScreenPosition = new Terminal.Gui.Point (graphView.Bounds.Width - 1, 10) }); + + graphView.SetNeedsDisplay (); + + } + + class DiscoBarSeries : BarSeries { + private Terminal.Gui.Attribute green; + private Terminal.Gui.Attribute brightgreen; + private Terminal.Gui.Attribute brightyellow; + private Terminal.Gui.Attribute red; + private Terminal.Gui.Attribute brightred; + + public DiscoBarSeries () + { + + green = Application.Driver.MakeAttribute (Color.BrightGreen, Color.Black); + brightgreen = Application.Driver.MakeAttribute (Color.Green, Color.Black); + brightyellow = Application.Driver.MakeAttribute (Color.BrightYellow, Color.Black); + red = Application.Driver.MakeAttribute (Color.Red, Color.Black); + brightred = Application.Driver.MakeAttribute (Color.BrightRed, Color.Black); + } + protected override void DrawBarLine (GraphView graph, Terminal.Gui.Point start, Terminal.Gui.Point end, Bar beingDrawn) + { + var driver = Application.Driver; + + int x = start.X; + for(int y = end.Y; y <= start.Y; y++) { + + var height = graph.ScreenToGraphSpace (x, y).Y; + + if (height >= 85) { + driver.SetAttribute(red); + } + else + if (height >= 66) { + driver.SetAttribute (brightred); + } + else + if (height >= 45) { + driver.SetAttribute (brightyellow); + } + else + if (height >= 25) { + driver.SetAttribute (brightgreen); + } + else{ + driver.SetAttribute (green); + } + + graph.AddRune (x, y, beingDrawn.Fill.Rune); + } + } + } + + private void SetupDisco () + { + graphView.Reset (); + + about.Text = "This graph shows a graphic equaliser for an imaginary song"; + + graphView.GraphColor = Application.Driver.MakeAttribute (Color.White, Color.Black); + + var stiple = new GraphCellToRender ('\u2593'); + + Random r = new Random (); + var series = new DiscoBarSeries (); + var bars = new List (); + + Func genSample = (l) => { + + bars.Clear (); + // generate an imaginary sample + for (int i = 0; i < 31; i++) { + bars.Add ( + new BarSeries.Bar (null, stiple, r.Next (0, 100)) { + //ColorGetter = colorDelegate + }); + } + graphView.SetNeedsDisplay (); + + + // while the equaliser is showing + return graphView.Series.Contains (series); + }; + + Application.MainLoop.AddTimeout (TimeSpan.FromMilliseconds (250), genSample); + + series.Bars = bars; + + graphView.Series.Add (series); + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (1, 10); + graphView.AxisX.Increment = 0; // No graph ticks + graphView.AxisX.ShowLabelsEvery = 0; // no labels + + graphView.AxisX.Visible = false; + graphView.AxisY.Visible = false; + + graphView.SetNeedsDisplay (); + } + private void SetupPeriodicTableScatterPlot () + { + graphView.Reset (); + + about.Text = "This graph shows the atomic weight of each element in the periodic table.\nStarting with Hydrogen (atomic Number 1 with a weight of 1.007)"; + + //AtomicNumber and AtomicMass of all elements in the periodic table + graphView.Series.Add ( + new ScatterSeries () { + Points = new List{ + new PointF(1,1.007f),new PointF(2,4.002f),new PointF(3,6.941f),new PointF(4,9.012f),new PointF(5,10.811f),new PointF(6,12.011f), + new PointF(7,14.007f),new PointF(8,15.999f),new PointF(9,18.998f),new PointF(10,20.18f),new PointF(11,22.99f),new PointF(12,24.305f), + new PointF(13,26.982f),new PointF(14,28.086f),new PointF(15,30.974f),new PointF(16,32.065f),new PointF(17,35.453f),new PointF(18,39.948f), + new PointF(19,39.098f),new PointF(20,40.078f),new PointF(21,44.956f),new PointF(22,47.867f),new PointF(23,50.942f),new PointF(24,51.996f), + new PointF(25,54.938f),new PointF(26,55.845f),new PointF(27,58.933f),new PointF(28,58.693f),new PointF(29,63.546f),new PointF(30,65.38f), + new PointF(31,69.723f),new PointF(32,72.64f),new PointF(33,74.922f),new PointF(34,78.96f),new PointF(35,79.904f),new PointF(36,83.798f), + new PointF(37,85.468f),new PointF(38,87.62f),new PointF(39,88.906f),new PointF(40,91.224f),new PointF(41,92.906f),new PointF(42,95.96f), + new PointF(43,98f),new PointF(44,101.07f),new PointF(45,102.906f),new PointF(46,106.42f),new PointF(47,107.868f),new PointF(48,112.411f), + new PointF(49,114.818f),new PointF(50,118.71f),new PointF(51,121.76f),new PointF(52,127.6f),new PointF(53,126.904f),new PointF(54,131.293f), + new PointF(55,132.905f),new PointF(56,137.327f),new PointF(57,138.905f),new PointF(58,140.116f),new PointF(59,140.908f),new PointF(60,144.242f), + new PointF(61,145),new PointF(62,150.36f),new PointF(63,151.964f),new PointF(64,157.25f),new PointF(65,158.925f),new PointF(66,162.5f), + new PointF(67,164.93f),new PointF(68,167.259f),new PointF(69,168.934f),new PointF(70,173.054f),new PointF(71,174.967f),new PointF(72,178.49f), + new PointF(73,180.948f),new PointF(74,183.84f),new PointF(75,186.207f),new PointF(76,190.23f),new PointF(77,192.217f),new PointF(78,195.084f), + new PointF(79,196.967f),new PointF(80,200.59f),new PointF(81,204.383f),new PointF(82,207.2f),new PointF(83,208.98f),new PointF(84,210), + new PointF(85,210),new PointF(86,222),new PointF(87,223),new PointF(88,226),new PointF(89,227),new PointF(90,232.038f),new PointF(91,231.036f), + new PointF(92,238.029f),new PointF(93,237),new PointF(94,244),new PointF(95,243),new PointF(96,247),new PointF(97,247),new PointF(98,251), + new PointF(99,252),new PointF(100,257),new PointF(101,258),new PointF(102,259),new PointF(103,262),new PointF(104,261),new PointF(105,262), + new PointF(106,266),new PointF(107,264),new PointF(108,267),new PointF(109,268),new PointF(113,284),new PointF(114,289),new PointF(115,288), + new PointF(116,292),new PointF(117,295),new PointF(118,294) + } + }); + + // How much graph space each cell of the console depicts + graphView.CellSize = new PointF (1, 5); + + // leave space for axis labels + graphView.MarginBottom = 2; + graphView.MarginLeft = 3; + + // One axis tick/label per 5 atomic numbers + graphView.AxisX.Increment = 5; + graphView.AxisX.ShowLabelsEvery = 1; + graphView.AxisX.Text = "Atomic Number"; + graphView.AxisX.Minimum = 0; + + // One label every 5 atomic weight + graphView.AxisY.Increment = 5; + graphView.AxisY.ShowLabelsEvery = 1; + graphView.AxisY.Minimum = 0; + + graphView.SetNeedsDisplay (); + } + + private void Zoom (float factor) + { + graphView.CellSize = new PointF ( + graphView.CellSize.X * factor, + graphView.CellSize.Y * factor + ); + + graphView.AxisX.Increment *= factor; + graphView.AxisY.Increment *= factor; + + graphView.SetNeedsDisplay (); + } + + private void Quit () + { + Application.RequestStop (); + } + } +} diff --git a/UnitTests/GraphViewTests.cs b/UnitTests/GraphViewTests.cs new file mode 100644 index 000000000..13ee1d0de --- /dev/null +++ b/UnitTests/GraphViewTests.cs @@ -0,0 +1,1307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Terminal.Gui; +using Xunit; +using Terminal.Gui.Graphs; +using Point = Terminal.Gui.Point; +using Attribute = Terminal.Gui.Attribute; +using System.Text; +using System.Text.RegularExpressions; + +namespace Terminal.Gui.Views { + + #region Helper Classes + class FakeHAxis : HorizontalAxis { + + public List DrawAxisLinePoints = new List (); + public List LabelPoints = new List(); + + protected override void DrawAxisLine (GraphView graph, int x, int y) + { + base.DrawAxisLine (graph, x, y); + DrawAxisLinePoints.Add (new Point(x, y)); + } + + public override void DrawAxisLabel (GraphView graph, int screenPosition, string text) + { + base.DrawAxisLabel (graph, screenPosition, text); + LabelPoints.Add(screenPosition); + } + } + + class FakeVAxis : VerticalAxis { + + public List DrawAxisLinePoints = new List (); + public List LabelPoints = new List(); + + protected override void DrawAxisLine (GraphView graph, int x, int y) + { + base.DrawAxisLine (graph, x, y); + DrawAxisLinePoints.Add (new Point(x, y)); + } + public override void DrawAxisLabel (GraphView graph, int screenPosition, string text) + { + base.DrawAxisLabel (graph, screenPosition, text); + LabelPoints.Add(screenPosition); + } + } + #endregion + + public class GraphViewTests { + + + public static FakeDriver InitFakeDriver () + { + var driver = new FakeDriver (); + Application.Init (driver, new FakeMainLoop (() => FakeConsole.ReadKey (true))); + driver.Init (() => { }); + return driver; + } + + /// + /// Returns a basic very small graph (10 x 5) + /// + /// + public static GraphView GetGraph () + { + GraphViewTests.InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + gv.MarginBottom = 1; + gv.MarginLeft = 1; + gv.Bounds = new Rect (0, 0, 10, 5); + + return gv; + } + +#pragma warning disable xUnit1013 // Public method should be marked as test + public static void AssertDriverContentsAre (string expectedLook) + { +#pragma warning restore xUnit1013 // Public method should be marked as test + + var sb = new StringBuilder (); + var driver = ((FakeDriver)Application.Driver); + + var contents = driver.Contents; + + for (int r = 0; r < driver.Rows; r++) { + for (int c = 0; c < driver.Cols; c++) { + sb.Append ((char)contents [r, c, 0]); + } + sb.AppendLine (); + } + + var actualLook = sb.ToString (); + + if (!string.Equals (expectedLook, actualLook)) { + + // ignore trailing whitespace on each line + var trailingWhitespace = new Regex (@"\s+$",RegexOptions.Multiline); + + // get rid of trailing whitespace on each line (and leading/trailing whitespace of start/end of full string) + expectedLook = trailingWhitespace.Replace(expectedLook,"").Trim(); + actualLook = trailingWhitespace.Replace (actualLook, "").Trim (); + + // standardise line endings for the comparison + expectedLook = expectedLook.Replace ("\r\n", "\n"); + actualLook = actualLook.Replace ("\r\n", "\n"); + + Console.WriteLine ("Expected:" + Environment.NewLine + expectedLook); + Console.WriteLine ("But Was:" + Environment.NewLine + actualLook); + + Assert.Equal (expectedLook, actualLook); + } + } + + #region Screen to Graph Tests + + [Fact] + public void ScreenToGraphSpace_DefaultCellSize () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // origin should be bottom left + var botLeft = gv.ScreenToGraphSpace (0, 9); + Assert.Equal (0, botLeft.X); + Assert.Equal (0, botLeft.Y); + Assert.Equal (1, botLeft.Width); + Assert.Equal (1, botLeft.Height); + + + // up 2 rows of the console and along 1 col + var up2along1 = gv.ScreenToGraphSpace (1, 7); + Assert.Equal (1, up2along1.X); + Assert.Equal (2, up2along1.Y); + } + [Fact] + public void ScreenToGraphSpace_DefaultCellSize_WithMargin () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // origin should be bottom left + var botLeft = gv.ScreenToGraphSpace (0, 9); + Assert.Equal (0, botLeft.X); + Assert.Equal (0, botLeft.Y); + Assert.Equal (1, botLeft.Width); + Assert.Equal (1, botLeft.Height); + + gv.MarginLeft = 1; + + botLeft = gv.ScreenToGraphSpace (0, 9); + // Origin should be at 1,9 now to leave a margin of 1 + // so screen position 0,9 would be data space -1,0 + Assert.Equal (-1, botLeft.X); + Assert.Equal (0, botLeft.Y); + Assert.Equal (1, botLeft.Width); + Assert.Equal (1, botLeft.Height); + + gv.MarginLeft = 1; + gv.MarginBottom = 1; + + botLeft = gv.ScreenToGraphSpace (0, 9); + // Origin should be at 1,0 (to leave a margin of 1 in both sides) + // so screen position 0,9 would be data space -1,-1 + Assert.Equal (-1, botLeft.X); + Assert.Equal (-1, botLeft.Y); + Assert.Equal (1, botLeft.Width); + Assert.Equal (1, botLeft.Height); + } + [Fact] + public void ScreenToGraphSpace_CustomCellSize () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // Each cell of screen measures 5 units in graph data model vertically and 1/4 horizontally + gv.CellSize = new PointF (0.25f, 5); + + // origin should be bottom left + // (note that y=10 is actually overspilling the control, the last row is 9) + var botLeft = gv.ScreenToGraphSpace (0, 9); + Assert.Equal (0, botLeft.X); + Assert.Equal (0, botLeft.Y); + Assert.Equal (0.25f, botLeft.Width); + Assert.Equal (5, botLeft.Height); + + // up 2 rows of the console and along 1 col + var up2along1 = gv.ScreenToGraphSpace (1, 7); + Assert.Equal (0.25f, up2along1.X); + Assert.Equal (10, up2along1.Y); + Assert.Equal (0.25f, botLeft.Width); + Assert.Equal (5, botLeft.Height); + } + + #endregion + + #region Graph to Screen Tests + + [Fact] + public void GraphSpaceToScreen_DefaultCellSize () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // origin should be bottom left + var botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (0, botLeft.X); + Assert.Equal (9, botLeft.Y); // row 9 of the view is the bottom left + + // along 2 and up 1 in graph space + var along2up1 = gv.GraphSpaceToScreen (new PointF (2, 1)); + Assert.Equal (2, along2up1.X); + Assert.Equal (8, along2up1.Y); + } + + [Fact] + public void GraphSpaceToScreen_DefaultCellSize_WithMargin () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // origin should be bottom left + var botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (0, botLeft.X); + Assert.Equal (9, botLeft.Y); // row 9 of the view is the bottom left + + gv.MarginLeft = 1; + + // With a margin of 1 the origin should be at x=1 y= 9 + botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (1, botLeft.X); + Assert.Equal (9, botLeft.Y); // row 9 of the view is the bottom left + + gv.MarginLeft = 1; + gv.MarginBottom = 1; + + // With a margin of 1 in both directions the origin should be at x=1 y= 9 + botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (1, botLeft.X); + Assert.Equal (8, botLeft.Y); // row 8 of the view is the bottom left up 1 cell + } + + [Fact] + public void GraphSpaceToScreen_ScrollOffset () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + //graph is scrolled to present chart space -5 to 5 in both axes + gv.ScrollOffset = new PointF (-5, -5); + + // origin should be right in the middle of the control + var botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (5, botLeft.X); + Assert.Equal (4, botLeft.Y); + + // along 2 and up 1 in graph space + var along2up1 = gv.GraphSpaceToScreen (new PointF (2, 1)); + Assert.Equal (7, along2up1.X); + Assert.Equal (3, along2up1.Y); + } + [Fact] + public void GraphSpaceToScreen_CustomCellSize () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // Each cell of screen is responsible for rendering 5 units in graph data model + // vertically and 1/4 horizontally + gv.CellSize = new PointF (0.25f, 5); + + // origin should be bottom left + var botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (0, botLeft.X); + // row 9 of the view is the bottom left (height is 10 so 0,1,2,3..9) + Assert.Equal (9, botLeft.Y); + + // along 2 and up 1 in graph space + var along2up1 = gv.GraphSpaceToScreen (new PointF (2, 1)); + Assert.Equal (8, along2up1.X); + Assert.Equal (9, along2up1.Y); + + // Y value 4 should be rendered in bottom most row + Assert.Equal (9, gv.GraphSpaceToScreen (new PointF (2, 4)).Y); + + // Cell height is 5 so this is the first point of graph space that should + // be rendered in the graph in next row up (row 9) + Assert.Equal (8, gv.GraphSpaceToScreen (new PointF (2, 5)).Y); + + // More boundary testing for this cell size + Assert.Equal (8, gv.GraphSpaceToScreen (new PointF (2, 6)).Y); + Assert.Equal (8, gv.GraphSpaceToScreen (new PointF (2, 7)).Y); + Assert.Equal (8, gv.GraphSpaceToScreen (new PointF (2, 8)).Y); + Assert.Equal (8, gv.GraphSpaceToScreen (new PointF (2, 9)).Y); + Assert.Equal (7, gv.GraphSpaceToScreen (new PointF (2, 10)).Y); + Assert.Equal (7, gv.GraphSpaceToScreen (new PointF (2, 11)).Y); + } + + + [Fact] + public void GraphSpaceToScreen_CustomCellSize_WithScrollOffset () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 20, 10); + + // Each cell of screen is responsible for rendering 5 units in graph data model + // vertically and 1/4 horizontally + gv.CellSize = new PointF (0.25f, 5); + + //graph is scrolled to present some negative chart (4 negative cols and 2 negative rows) + gv.ScrollOffset = new PointF (-1, -10); + + // origin should be in the lower left (but not right at the bottom) + var botLeft = gv.GraphSpaceToScreen (new PointF (0, 0)); + Assert.Equal (4, botLeft.X); + Assert.Equal (7, botLeft.Y); + + // along 2 and up 1 in graph space + var along2up1 = gv.GraphSpaceToScreen (new PointF (2, 1)); + Assert.Equal (12, along2up1.X); + Assert.Equal (7, along2up1.Y); + + + // More boundary testing for this cell size/offset + Assert.Equal (6, gv.GraphSpaceToScreen (new PointF (2, 6)).Y); + Assert.Equal (6, gv.GraphSpaceToScreen (new PointF (2, 7)).Y); + Assert.Equal (6, gv.GraphSpaceToScreen (new PointF (2, 8)).Y); + Assert.Equal (6, gv.GraphSpaceToScreen (new PointF (2, 9)).Y); + Assert.Equal (5, gv.GraphSpaceToScreen (new PointF (2, 10)).Y); + Assert.Equal (5, gv.GraphSpaceToScreen (new PointF (2, 11)).Y); + } + + #endregion + + + /// + /// A cell size of 0 would result in mapping all graph space into the + /// same cell of the console. Since + /// is mutable a sensible place to check this is in redraw. + /// + [Fact] + public void CellSizeZero() + { + InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + gv.Bounds = new Rect (0, 0, 50, 30); + gv.Series.Add (new ScatterSeries () { Points = new List { new PointF (1, 1) } }); + gv.CellSize= new PointF(0,5); + var ex = Assert.Throws(()=>gv.Redraw (gv.Bounds)); + + Assert.Equal ("CellSize cannot be 0", ex.Message); + } + + + + /// + /// Tests that each point in the screen space maps to a rectangle of + /// (float) graph space and that each corner of that rectangle of graph + /// space maps back to the same row/col of the graph that was fed in + /// + [Fact] + public void TestReversing_ScreenToGraphSpace () + { + var gv = new GraphView (); + gv.Bounds = new Rect (0, 0, 50, 30); + + // How much graph space each cell of the console depicts + gv.CellSize = new PointF (0.1f, 0.25f); + gv.AxisX.Increment = 1; + gv.AxisX.ShowLabelsEvery = 1; + + gv.AxisY.Increment = 1; + gv.AxisY.ShowLabelsEvery = 1; + + // Start the graph at 80 + gv.ScrollOffset = new PointF (0, 80); + + for (int x = 0; x < gv.Bounds.Width; x++) { + for (int y = 0; y < gv.Bounds.Height; y++) { + + var graphSpace = gv.ScreenToGraphSpace (x, y); + + // See + // https://en.wikipedia.org/wiki/Machine_epsilon + float epsilon = 0.0001f; + + var p = gv.GraphSpaceToScreen (new PointF (graphSpace.Left + epsilon, graphSpace.Top + epsilon)); + Assert.Equal (x, p.X); + Assert.Equal (y, p.Y); + + p = gv.GraphSpaceToScreen (new PointF (graphSpace.Right - epsilon , graphSpace.Top + epsilon)); + Assert.Equal (x, p.X); + Assert.Equal (y, p.Y); + + p = gv.GraphSpaceToScreen (new PointF (graphSpace.Left + epsilon, graphSpace.Bottom - epsilon)); + Assert.Equal (x, p.X); + Assert.Equal (y, p.Y); + + p = gv.GraphSpaceToScreen (new PointF (graphSpace.Right - epsilon, graphSpace.Bottom - epsilon)); + Assert.Equal (x, p.X); + Assert.Equal (y, p.Y); + + } + } + } + } + + public class SeriesTests { + + [Fact] + public void Series_GetsPassedCorrectBounds_AllAtOnce () + { + GraphViewTests.InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + gv.Bounds = new Rect (0, 0, 50, 30); + + RectangleF fullGraphBounds = RectangleF.Empty; + Rect graphScreenBounds = Rect.Empty; + + var series = new FakeSeries ((v, s, g) => { graphScreenBounds = s; fullGraphBounds = g; }); + gv.Series.Add (series); + + + gv.Redraw (gv.Bounds); + Assert.Equal (new RectangleF (0, 0, 50, 30), fullGraphBounds); + Assert.Equal (new Rect (0, 0, 50, 30), graphScreenBounds); + + // Now we put a margin in + // Graph should not spill into the margins + + gv.MarginBottom = 2; + gv.MarginLeft = 5; + + // Even with a margin the graph should be drawn from + // the origin, we just get less visible width/height + gv.Redraw (gv.Bounds); + Assert.Equal (new RectangleF (0, 0, 45, 28), fullGraphBounds); + + // The screen space the graph will be rendered into should + // not overspill the margins + Assert.Equal (new Rect (5, 0, 45, 28), graphScreenBounds); + } + + /// + /// Tests that the bounds passed to the ISeries for drawing into are + /// correct even when the results in + /// multiple units of graph space being condensed into each cell of + /// console + /// + [Fact] + public void Series_GetsPassedCorrectBounds_AllAtOnce_LargeCellSize () + { + GraphViewTests.InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + gv.Bounds = new Rect (0, 0, 50, 30); + + // the larger the cell size the more condensed (smaller) the graph space is + gv.CellSize = new PointF (2, 5); + + RectangleF fullGraphBounds = RectangleF.Empty; + Rect graphScreenBounds = Rect.Empty; + + var series = new FakeSeries ((v, s, g) => { graphScreenBounds = s; fullGraphBounds = g; }); + + gv.Series.Add (series); + + gv.Redraw (gv.Bounds); + // Since each cell of the console is 2x5 of graph space the graph + // bounds to be rendered are larger + Assert.Equal (new RectangleF (0, 0, 100, 150), fullGraphBounds); + Assert.Equal (new Rect (0, 0, 50, 30), graphScreenBounds); + + // Graph should not spill into the margins + + gv.MarginBottom = 2; + gv.MarginLeft = 5; + + // Even with a margin the graph should be drawn from + // the origin, we just get less visible width/height + gv.Redraw (gv.Bounds); + Assert.Equal (new RectangleF (0, 0, 90, 140), fullGraphBounds); + + // The screen space the graph will be rendered into should + // not overspill the margins + Assert.Equal (new Rect (5, 0, 45, 28), graphScreenBounds); + } + + private class FakeSeries : ISeries { + + readonly Action drawSeries; + + public FakeSeries ( + Action drawSeries + ) + { + this.drawSeries = drawSeries; + } + + public void DrawSeries (GraphView graph, Rect bounds, RectangleF graphBounds) + { + drawSeries (graph, bounds, graphBounds); + } + } + } + + public class MultiBarSeriesTests{ + + + [Fact] + public void MultiBarSeries_BarSpacing(){ + + // Creates clusters of 5 adjacent bars with 2 spaces between clusters + var series = new MultiBarSeries(5,7,1); + + Assert.Equal(5,series.SubSeries.Count); + + Assert.Equal(0,series.SubSeries.ElementAt(0).Offset); + Assert.Equal(1,series.SubSeries.ElementAt(1).Offset); + Assert.Equal(2,series.SubSeries.ElementAt(2).Offset); + Assert.Equal(3,series.SubSeries.ElementAt(3).Offset); + Assert.Equal(4,series.SubSeries.ElementAt(4).Offset); + } + + + [Fact] + public void MultiBarSeriesColors_WrongNumber(){ + + var fake = new FakeDriver (); + + var colors = new []{ + fake.MakeAttribute(Color.Green,Color.Black) + }; + + // user passes 1 color only but asks for 5 bars + var ex = Assert.Throws(()=>new MultiBarSeries(5,7,1,colors)); + Assert.Equal("Number of colors must match the number of bars (Parameter 'numberOfBarsPerCategory')",ex.Message); + } + + + [Fact] + public void MultiBarSeriesColors_RightNumber(){ + + var fake = new FakeDriver (); + + var colors = new []{ + fake.MakeAttribute(Color.Green,Color.Black), + fake.MakeAttribute(Color.Green,Color.White), + fake.MakeAttribute(Color.BrightYellow,Color.White) + }; + + // user passes 3 colors and asks for 3 bars + var series = new MultiBarSeries(3,7,1,colors); + + Assert.Equal(series.SubSeries.ElementAt(0).OverrideBarColor,colors[0]); + Assert.Equal(series.SubSeries.ElementAt(1).OverrideBarColor,colors[1]); + Assert.Equal(series.SubSeries.ElementAt(2).OverrideBarColor,colors[2]); + } + + + [Fact] + public void MultiBarSeriesAddValues_WrongNumber(){ + + // user asks for 3 bars per category + var series = new MultiBarSeries(3,7,1); + + var ex = Assert.Throws(()=>series.AddBars("Cars",'#',1)); + + Assert.Equal("Number of values must match the number of bars per category (Parameter 'values')",ex.Message); + } + + + + [Fact] + public void TestRendering_MultibarSeries(){ + + GraphViewTests.InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + + // y axis goes from 0.1 to 1 across 10 console rows + // x axis goes from 0 to 20 across 20 console columns + gv.Bounds = new Rect (0, 0, 20, 10); + gv.CellSize = new PointF(1f,0.1f); + gv.MarginBottom = 1; + gv.MarginLeft = 1; + + var multibarSeries = new MultiBarSeries (2,4,1); + + //nudge them left to avoid float rounding errors at the boundaries of cells + foreach(var sub in multibarSeries.SubSeries) { + sub.Offset -= 0.001f; + } + + gv.Series.Add (multibarSeries); + + FakeHAxis fakeXAxis; + + // don't show axis labels that means any labels + // that appaer are explicitly from the bars + gv.AxisX = fakeXAxis = new FakeHAxis(){Increment=0}; + gv.AxisY = new FakeVAxis(){Increment=0}; + + gv.Redraw(gv.Bounds); + + // Since bar series has no bars yet no labels should be displayed + Assert.Empty(fakeXAxis.LabelPoints); + + multibarSeries.AddBars("hey",'M',0.5001f, 0.5001f); + fakeXAxis.LabelPoints.Clear(); + gv.Redraw(gv.Bounds); + + Assert.Equal(4,fakeXAxis.LabelPoints.Single()); + + multibarSeries.AddBars("there",'M',0.24999f,0.74999f); + multibarSeries.AddBars("bob",'M',1,2); + fakeXAxis.LabelPoints.Clear(); + gv.Redraw(gv.Bounds); + + Assert.Equal(3,fakeXAxis.LabelPoints.Count); + Assert.Equal(4,fakeXAxis.LabelPoints[0]); + Assert.Equal(8,fakeXAxis.LabelPoints[1]); + Assert.Equal (12, fakeXAxis.LabelPoints [2]); + + string looksLike = +@" + │ MM + │ M MM + │ M MM + │ MM M MM + │ MM M MM + │ MM M MM + │ MM MM MM + │ MM MM MM + ┼──┬M──┬M──┬M────── + heytherebob "; + GraphViewTests.AssertDriverContentsAre (looksLike); + } + } + + public class BarSeriesTests{ + + + private GraphView GetGraph (out FakeBarSeries series, out FakeHAxis axisX, out FakeVAxis axisY) + { + GraphViewTests.InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + + // y axis goes from 0.1 to 1 across 10 console rows + // x axis goes from 0 to 10 across 20 console columns + gv.Bounds = new Rect (0, 0, 20, 10); + gv.CellSize = new PointF(0.5f,0.1f); + + gv.Series.Add (series = new FakeBarSeries ()); + + // don't show axis labels that means any labels + // that appaer are explicitly from the bars + gv.AxisX = axisX = new FakeHAxis(){Increment=0}; + gv.AxisY = axisY = new FakeVAxis(){Increment=0}; + + return gv; + } + + [Fact] + public void TestZeroHeightBar_WithName(){ + + var graph = GetGraph(out FakeBarSeries barSeries, out FakeHAxis axisX, out FakeVAxis axisY); + graph.Redraw(graph.Bounds); + + // no bars + Assert.Empty(barSeries.BarScreenStarts); + Assert.Empty(axisX.LabelPoints); + Assert.Empty(axisY.LabelPoints); + + // bar of height 0 + barSeries.Bars.Add(new BarSeries.Bar("hi",new GraphCellToRender('.'),0)); + barSeries.Orientation = Orientation.Vertical; + + // redraw graph + graph.Redraw(graph.Bounds); + + // bar should not be drawn + Assert.Empty(barSeries.BarScreenStarts); + + Assert.NotEmpty(axisX.LabelPoints); + Assert.Empty(axisY.LabelPoints); + + // but bar name should be + // Screen position x=2 because bars are drawn every 1f of + // graph space and CellSize.X is 0.5f + Assert.Contains(2, axisX.LabelPoints); + } + + + [Fact] + public void TestTwoTallBars_WithOffset(){ + + var graph = GetGraph(out FakeBarSeries barSeries, out FakeHAxis axisX, out FakeVAxis axisY); + graph.Redraw(graph.Bounds); + + // no bars + Assert.Empty(barSeries.BarScreenStarts); + Assert.Empty(axisX.LabelPoints); + Assert.Empty(axisY.LabelPoints); + + // 0.5 units of graph fit every screen cell + // so 1 unit of graph space is 2 screen columns + graph.CellSize = new PointF(0.5f,0.1f); + + // Start bar 1 screen unit along + barSeries.Offset = 0.5f; + barSeries.BarEvery = 1f; + + barSeries.Bars.Add( + new BarSeries.Bar("hi1",new GraphCellToRender('.'),100)); + barSeries.Bars.Add( + new BarSeries.Bar("hi2",new GraphCellToRender('.'),100)); + + barSeries.Orientation = Orientation.Vertical; + + // redraw graph + graph.Redraw(graph.Bounds); + + // bar should be drawn at BarEvery 1f + offset 0.5f = 3 screen units + Assert.Equal(3,barSeries.BarScreenStarts[0].X); + Assert.Equal(3,barSeries.BarScreenEnds[0].X); + + // second bar should be BarEveryx2 = 2f + offset 0.5f = 5 screen units + Assert.Equal(5,barSeries.BarScreenStarts[1].X); + Assert.Equal(5,barSeries.BarScreenEnds[1].X); + + // both bars should have labels + Assert.Equal(2,axisX.LabelPoints.Count); + Assert.Contains(3, axisX.LabelPoints); + Assert.Contains(5, axisX.LabelPoints); + + // bars are very tall but should not draw up off top of screen + Assert.Equal(9,barSeries.BarScreenStarts[0].Y); + Assert.Equal(0,barSeries.BarScreenEnds[0].Y); + Assert.Equal(9,barSeries.BarScreenStarts[1].Y); + Assert.Equal(0,barSeries.BarScreenEnds[1].Y); + } + + + + [Fact] + public void TestOneLongOneShortHorizontalBars_WithOffset(){ + + var graph = GetGraph(out FakeBarSeries barSeries, out FakeHAxis axisX, out FakeVAxis axisY); + graph.Redraw(graph.Bounds); + + // no bars + Assert.Empty(barSeries.BarScreenStarts); + Assert.Empty(axisX.LabelPoints); + Assert.Empty(axisY.LabelPoints); + + // 0.1 units of graph y fit every screen row + // so 1 unit of graph y space is 10 screen rows + graph.CellSize = new PointF(0.5f,0.1f); + + // Start bar 3 screen units up (y = height-3) + barSeries.Offset = 0.25f; + // 1 bar every 3 rows of screen + barSeries.BarEvery = 0.3f; + barSeries.Orientation = Orientation.Horizontal; + + // 1 bar that is very wide (100 graph units horizontally = screen pos 50 but bounded by screen) + barSeries.Bars.Add( + new BarSeries.Bar("hi1",new GraphCellToRender('.'),100)); + + // 1 bar that is shorter + barSeries.Bars.Add( + new BarSeries.Bar("hi2",new GraphCellToRender('.'),5)); + + // redraw graph + graph.Redraw(graph.Bounds); + + // since bars are horizontal all have the same X start cordinates + Assert.Equal(0,barSeries.BarScreenStarts[0].X); + Assert.Equal(0,barSeries.BarScreenStarts[1].X); + + // bar goes all the way to the end so bumps up against right screen boundary + // width of graph is 20 + Assert.Equal(19,barSeries.BarScreenEnds[0].X); + + // shorter bar is 5 graph units wide which is 10 screen units + Assert.Equal(10,barSeries.BarScreenEnds[1].X); + + // first bar should be offset 6 screen units (0.25f + 0.3f graph units) + // since height of control is 10 then first bar should be at screen row 4 (10-6) + Assert.Equal(4,barSeries.BarScreenStarts[0].Y); + + // second bar should be offset 9 screen units (0.25f + 0.6f graph units) + // since height of control is 10 then second bar should be at screen row 1 (10-9) + Assert.Equal(1,barSeries.BarScreenStarts[1].Y); + + // both bars should have labels but on the y axis + Assert.Equal(2,axisY.LabelPoints.Count); + Assert.Empty(axisX.LabelPoints); + + // labels should align with the bars (same screen y axis point) + Assert.Contains(4, axisY.LabelPoints); + Assert.Contains(1, axisY.LabelPoints); + } + + private class FakeBarSeries : BarSeries{ + public GraphCellToRender FinalColor { get; private set; } + + public List BarScreenStarts { get; private set; } = new List(); + public List BarScreenEnds { get; private set; } = new List(); + + protected override GraphCellToRender AdjustColor (GraphCellToRender graphCellToRender) + { + return FinalColor = base.AdjustColor (graphCellToRender); + } + + protected override void DrawBarLine (GraphView graph, Point start, Point end, Bar beingDrawn) + { + base.DrawBarLine (graph, start, end, beingDrawn); + + BarScreenStarts.Add(start); + BarScreenEnds.Add(end); + } + + } + } + + + public class AxisTests { + + + private GraphView GetGraph (out FakeHAxis axis) + { + return GetGraph(out axis, out _); + } + private GraphView GetGraph (out FakeVAxis axis) + { + return GetGraph(out _, out axis); + } + private GraphView GetGraph (out FakeHAxis axisX, out FakeVAxis axisY) + { + GraphViewTests.InitFakeDriver (); + + var gv = new GraphView (); + gv.ColorScheme = new ColorScheme (); + gv.Bounds = new Rect (0, 0, 50, 30); + // graph can't be completely empty or it won't draw + gv.Series.Add (new ScatterSeries ()); + + axisX = new FakeHAxis (); + axisY = new FakeVAxis (); + gv.AxisX = axisX; + gv.AxisY = axisY; + + return gv; + } + + #region HorizontalAxis Tests + + /// + /// Tests that the horizontal axis is computed correctly and does not over spill + /// it's bounds + /// + [Fact] + public void TestHAxisLocation_NoMargin () + { + var gv = GetGraph (out FakeHAxis axis); + + gv.Redraw (gv.Bounds); + + Assert.DoesNotContain (new Point (-1, 29), axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 29),axis.DrawAxisLinePoints); + Assert.Contains (new Point (1, 29), axis.DrawAxisLinePoints); + + Assert.Contains (new Point (48, 29), axis.DrawAxisLinePoints); + Assert.Contains (new Point (49, 29), axis.DrawAxisLinePoints); + Assert.DoesNotContain (new Point (50, 29), axis.DrawAxisLinePoints); + + Assert.InRange(axis.LabelPoints.Max(),0,49); + Assert.InRange(axis.LabelPoints.Min(),0,49); + } + + [Fact] + public void TestHAxisLocation_MarginBottom () + { + var gv = GetGraph (out FakeHAxis axis); + + gv.MarginBottom = 10; + gv.Redraw (gv.Bounds); + + Assert.DoesNotContain (new Point (-1, 19), axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 19), axis.DrawAxisLinePoints); + Assert.Contains (new Point (1, 19), axis.DrawAxisLinePoints); + + Assert.Contains (new Point (48, 19), axis.DrawAxisLinePoints); + Assert.Contains (new Point (49, 19), axis.DrawAxisLinePoints); + Assert.DoesNotContain (new Point (50, 19), axis.DrawAxisLinePoints); + + Assert.InRange(axis.LabelPoints.Max(),0,49); + Assert.InRange(axis.LabelPoints.Min(),0,49); + } + + [Fact] + public void TestHAxisLocation_MarginLeft () + { + var gv = GetGraph (out FakeHAxis axis); + + gv.MarginLeft = 5; + gv.Redraw (gv.Bounds); + + Assert.DoesNotContain (new Point (4, 29), axis.DrawAxisLinePoints); + Assert.Contains (new Point (5, 29), axis.DrawAxisLinePoints); + Assert.Contains (new Point (6, 29), axis.DrawAxisLinePoints); + + Assert.Contains (new Point (48, 29), axis.DrawAxisLinePoints); + Assert.Contains (new Point (49, 29), axis.DrawAxisLinePoints); + Assert.DoesNotContain (new Point (50, 29), axis.DrawAxisLinePoints); + + // Axis lables should not be drawn in the margin + Assert.InRange(axis.LabelPoints.Max(),5,49); + Assert.InRange(axis.LabelPoints.Min(),5,49); + } + + #endregion + + #region VerticalAxisTests + + + /// + /// Tests that the horizontal axis is computed correctly and does not over spill + /// it's bounds + /// + [Fact] + public void TestVAxisLocation_NoMargin () + { + var gv = GetGraph (out FakeVAxis axis); + + gv.Redraw (gv.Bounds); + + Assert.DoesNotContain (new Point (0, -1), axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 1),axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 2), axis.DrawAxisLinePoints); + + Assert.Contains (new Point (0, 28), axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 29), axis.DrawAxisLinePoints); + Assert.DoesNotContain (new Point (0, 30), axis.DrawAxisLinePoints); + + Assert.InRange(axis.LabelPoints.Max(),0,29); + Assert.InRange(axis.LabelPoints.Min(),0,29); + } + + [Fact] + public void TestVAxisLocation_MarginBottom () + { + var gv = GetGraph (out FakeVAxis axis); + + gv.MarginBottom = 10; + gv.Redraw (gv.Bounds); + + Assert.DoesNotContain (new Point (0, -1), axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 1),axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 2), axis.DrawAxisLinePoints); + + Assert.Contains (new Point (0, 18), axis.DrawAxisLinePoints); + Assert.Contains (new Point (0, 19), axis.DrawAxisLinePoints); + Assert.DoesNotContain (new Point (0, 20), axis.DrawAxisLinePoints); + + // Labels should not be drawn into the axis + Assert.InRange(axis.LabelPoints.Max(),0,19); + Assert.InRange(axis.LabelPoints.Min(),0,19); + } + + [Fact] + public void TestVAxisLocation_MarginLeft () + { + var gv = GetGraph (out FakeVAxis axis); + + gv.MarginLeft = 5; + gv.Redraw (gv.Bounds); + + Assert.DoesNotContain (new Point (5, -1), axis.DrawAxisLinePoints); + Assert.Contains (new Point (5, 1),axis.DrawAxisLinePoints); + Assert.Contains (new Point (5, 2), axis.DrawAxisLinePoints); + + Assert.Contains (new Point (5, 28), axis.DrawAxisLinePoints); + Assert.Contains (new Point (5, 29), axis.DrawAxisLinePoints); + Assert.DoesNotContain (new Point (5, 30), axis.DrawAxisLinePoints); + + Assert.InRange(axis.LabelPoints.Max(),0,29); + Assert.InRange(axis.LabelPoints.Min(),0,29); + } + + #endregion + + + + } + + public class TextAnnotationTests { + + [Fact] + public void TestTextAnnotation_ScreenUnits() + { + var gv = GraphViewTests.GetGraph (); + + gv.Annotations.Add (new TextAnnotation () { + Text = "hey!", + ScreenPosition = new Point (3, 1) + }); + + gv.Redraw (gv.Bounds); + + var expected = +@" + │ + ┤ hey! + ┤ +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + // user scrolls up one unit of graph space + gv.ScrollOffset = new PointF (0, 1f); + gv.Redraw (gv.Bounds); + + // we expect no change in the location of the annotation (only the axis label changes) + // this is because screen units are constant and do not change as the viewport into + // graph space scrolls to different areas of the graph + expected = +@" + │ + ┤ hey! + ┤ +1┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + } + + + [Fact] + public void TestTextAnnotation_GraphUnits () + { + var gv = GraphViewTests.GetGraph (); + + gv.Annotations.Add (new TextAnnotation () { + Text = "hey!", + GraphPosition = new PointF (2, 2) + }); + + gv.Redraw (gv.Bounds); + + var expected = +@" + │ + ┤ hey! + ┤ +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + // user scrolls up one unit of graph space + gv.ScrollOffset = new PointF (0, 1f); + gv.Redraw (gv.Bounds); + + // we expect the text annotation to go down one line since + // the scroll offset means that that point of graph space is + // lower down in the view. Note the 1 on the axis too, our viewport + // (excluding margins) now shows y of 1 to 4 (previously 0 to 5) + expected = +@" + │ + ┤ + ┤ hey! +1┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + } + + [Fact] + public void TestTextAnnotation_LongText () + { + var gv = GraphViewTests.GetGraph (); + + gv.Annotations.Add (new TextAnnotation () { + Text = "hey there partner hows it going boy its great", + GraphPosition = new PointF (2, 2) + }); + + gv.Redraw (gv.Bounds); + + // long text should get truncated + // margin takes up 1 units + // the GraphPosition of the anntation is 2 + // Leaving 7 characters of the annotation renderable (including space) + var expected = +@" + │ + ┤ hey the + ┤ +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + + + [Fact] + public void TestTextAnnotation_Offscreen () + { + var gv = GraphViewTests.GetGraph (); + + gv.Annotations.Add (new TextAnnotation () { + Text = "hey there partner hows it going boy its great", + GraphPosition = new PointF (9, 2) + }); + + gv.Redraw (gv.Bounds); + + // Text is off the screen (graph x axis runs to 8 not 9) + var expected = +@" + │ + ┤ + ┤ +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + + [Theory] + [InlineData(null)] + [InlineData (" ")] + [InlineData ("\t\t")] + public void TestTextAnnotation_EmptyText (string whitespace) + { + var gv = GraphViewTests.GetGraph (); + + gv.Annotations.Add (new TextAnnotation () { + Text = whitespace, + GraphPosition = new PointF (4, 2) + }); + + // add a point a bit further along the graph so if the whitespace were rendered + // the test would pick it up (AssertDriverContentsAre ignores trailing whitespace on lines) + var points = new ScatterSeries (); + points.Points.Add(new PointF(7, 2)); + gv.Series.Add (points); + + gv.Redraw (gv.Bounds); + + var expected = +@" + │ + ┤ x + ┤ +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + } + + public class LegendTests { + + [Fact] + public void LegendNormalUsage_WithBorder () + { + var gv = GraphViewTests.GetGraph (); + var legend = new LegendAnnotation(new Rect(2,0,5,3)); + legend.AddEntry (new GraphCellToRender ('A'), "Ant"); + legend.AddEntry (new GraphCellToRender ('B'), "Bat"); + + gv.Annotations.Add (legend); + gv.Redraw (gv.Bounds); + + var expected = +@" + │┌───┐ + ┤│AAn│ + ┤└───┘ +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + + [Fact] + public void LegendNormalUsage_WithoutBorder () + { + var gv = GraphViewTests.GetGraph (); + var legend = new LegendAnnotation (new Rect (2, 0, 5, 3)); + legend.AddEntry (new GraphCellToRender ('A'), "Ant"); + legend.AddEntry (new GraphCellToRender ('B'), "?"); // this will exercise pad + legend.AddEntry (new GraphCellToRender ('C'), "Cat"); + legend.AddEntry (new GraphCellToRender ('H'), "Hattter"); // not enough space for this oen + legend.Border = false; + + gv.Annotations.Add (legend); + gv.Redraw (gv.Bounds); + + var expected = +@" + │AAnt + ┤B? + ┤CCat +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + } + + public class PathAnnotationTests { + + [Fact] + public void PathAnnotation_Box() + { + var gv = GraphViewTests.GetGraph (); + + var path = new PathAnnotation (); + path.Points.Add (new PointF (1, 1)); + path.Points.Add (new PointF (1, 3)); + path.Points.Add (new PointF (6, 3)); + path.Points.Add (new PointF (6, 1)); + + // list the starting point again so that it draws a complete square + // (otherwise it will miss out the last line along the bottom) + path.Points.Add (new PointF (1, 1)); + + gv.Annotations.Add (path); + gv.Redraw (gv.Bounds); + + var expected = +@" + │...... + ┤. . + ┤...... +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + + [Fact] + public void PathAnnotation_Diamond () + { + var gv = GraphViewTests.GetGraph (); + + var path = new PathAnnotation (); + path.Points.Add (new PointF (1, 2)); + path.Points.Add (new PointF (3, 3)); + path.Points.Add (new PointF (6, 2)); + path.Points.Add (new PointF (3, 1)); + + // list the starting point again to close the shape + path.Points.Add (new PointF (1, 2)); + + gv.Annotations.Add (path); + gv.Redraw (gv.Bounds); + + var expected = +@" + │ .. + ┤.. .. + ┤ ... +0┼┬┬┬┬┬┬┬┬ + 0 5"; + + GraphViewTests.AssertDriverContentsAre (expected); + + } + } + + public class AxisIncrementToRenderTests { + [Fact] + public void AxisIncrementToRenderTests_Constructor () + { + var render = new AxisIncrementToRender (Orientation.Horizontal,1,6.6f); + + Assert.Equal (Orientation.Horizontal, render.Orientation); + Assert.Equal (1, render.ScreenLocation); + Assert.Equal (6.6f, render.Value); + } + } +} diff --git a/UnitTests/TabViewTests.cs b/UnitTests/TabViewTests.cs index d892181f0..759f10fc8 100644 --- a/UnitTests/TabViewTests.cs +++ b/UnitTests/TabViewTests.cs @@ -8,6 +8,7 @@ using Xunit; using System.Globalization; namespace Terminal.Gui.Views { + public class TabViewTests { private TabView GetTabView () { diff --git a/UnitTests/TableViewTests.cs b/UnitTests/TableViewTests.cs index 1405c654b..c964d6d8b 100644 --- a/UnitTests/TableViewTests.cs +++ b/UnitTests/TableViewTests.cs @@ -8,6 +8,7 @@ using Xunit; using System.Globalization; namespace Terminal.Gui.Views { + public class TableViewTests { diff --git a/UnitTests/TreeViewTests.cs b/UnitTests/TreeViewTests.cs index 47a219f25..03439a47a 100644 --- a/UnitTests/TreeViewTests.cs +++ b/UnitTests/TreeViewTests.cs @@ -8,6 +8,7 @@ using Terminal.Gui.Trees; using Xunit; namespace Terminal.Gui.Views { + public class TreeViewTests { #region Test Setup Methods class Factory {