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 {