Ver Fonte

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
Thomas Nind há 4 anos atrás
pai
commit
a8628e7c28

+ 5 - 0
Terminal.Gui/ConsoleDrivers/FakeDriver/FakeDriver.cs

@@ -26,6 +26,11 @@ namespace Terminal.Gui {
 		int [,,] contents;
 		bool [] dirtyLine;
 
+		/// <summary>
+		/// Assists with testing, the format is rows, columns and 3 values on the last column: Rune, Attribute and Dirty Flag
+		/// </summary>
+		public int [,,] Contents => contents;
+
 		void UpdateOffscreen ()
 		{
 			int cols = Cols;

+ 310 - 0
Terminal.Gui/Core/Graphs/Annotations.cs

@@ -0,0 +1,310 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Terminal.Gui.Graphs {
+	/// <summary>
+	/// <para>Describes an overlay element that is rendered either before or
+	/// after a series.</para>
+	/// 
+	/// <para>Annotations can be positioned either in screen space (e.g.
+	/// a legend) or in graph space (e.g. a line showing high point)
+	/// </para>
+	/// <para>Unlike <see cref="ISeries"/>, annotations are allowed to
+	/// draw into graph margins
+	/// </para>
+	/// </summary>
+	public interface IAnnotation {
+		/// <summary>
+		/// True if annotation should be drawn before <see cref="ISeries"/>.  This
+		/// allowes Series and later annotations to potentially draw over the top
+		/// of this annotation.
+		/// </summary>
+		bool BeforeSeries { get; }
+
+		/// <summary>
+		/// Called once after series have been rendered (or before if <see cref="BeforeSeries"/> is true).
+		/// Use <see cref="View.Driver"/> to draw and <see cref="View.Bounds"/> to avoid drawing outside of
+		/// graph
+		/// </summary>
+		/// <param name="graph"></param>
+		void Render (GraphView graph);
+	}
+
+
+	/// <summary>
+	/// Displays text at a given position (in screen space or graph space)
+	/// </summary>
+	public class TextAnnotation : IAnnotation {
+
+		/// <summary>
+		/// The location on screen to draw the <see cref="Text"/> regardless
+		/// of scroll/zoom settings.  This overrides <see cref="GraphPosition"/>
+		/// if specified.
+		/// </summary>
+		public Point? ScreenPosition { get; set; }
+
+		/// <summary>
+		/// The location in graph space to draw the <see cref="Text"/>.  This
+		/// annotation will only show if the point is in the current viewable
+		/// area of the graph presented in the <see cref="GraphView"/>
+		/// </summary>
+		public PointF GraphPosition { get; set; }
+
+		/// <summary>
+		/// Text to display on the graph
+		/// </summary>
+		public string Text { get; set; }
+
+		/// <summary>
+		/// True to add text before plotting series.  Defaults to false
+		/// </summary>
+		public bool BeforeSeries { get; set; }
+
+		/// <summary>
+		/// Draws the annotation
+		/// </summary>
+		/// <param name="graph"></param>
+		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);
+		}
+
+		/// <summary>
+		/// Draws the <see cref="Text"/> at the given coordinates with truncation to avoid
+		/// spilling over <see name="View.Bounds"/> of the <paramref name="graph"/>
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="x">Screen x position to start drawing string</param>
+		/// <param name="y">Screen y position to start drawing string</param>
+		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));
+			}
+		}
+	}
+
+	/// <summary>
+	/// A box containing symbol definitions e.g. meanings for colors in a graph.
+	/// The 'Key' to the graph
+	/// </summary>
+	public class LegendAnnotation : IAnnotation {
+
+		/// <summary>
+		/// True to draw a solid border around the legend.
+		/// Defaults to true.  This border will be within the
+		/// <see cref="Bounds"/> and so reduces the width/height
+		/// available for text by 2
+		/// </summary>
+		public bool Border { get; set; } = true;
+
+		/// <summary>
+		/// Defines the screen area available for the legend to render in
+		/// </summary>
+		public Rect Bounds { get; set; }
+
+		/// <summary>
+		/// Returns false i.e. Lengends render after series
+		/// </summary>
+		public bool BeforeSeries => false;
+
+		/// <summary>
+		/// Ordered collection of entries that are rendered in the legend.
+		/// </summary>
+		List<Tuple<GraphCellToRender, string>> entries = new List<Tuple<GraphCellToRender, string>> ();
+
+		/// <summary>
+		/// Creates a new empty legend at the given screen coordinates
+		/// </summary>
+		/// <param name="legendBounds">Defines the area available for the legend to render in
+		/// (within the graph).  This is in screen units (i.e. not graph space)</param>
+		public LegendAnnotation (Rect legendBounds)
+		{
+			Bounds = legendBounds;
+		}
+
+		/// <summary>
+		/// Draws the Legend and all entries into the area within <see cref="Bounds"/>
+		/// </summary>
+		/// <param name="graph"></param>
+		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 ());
+		}
+
+		/// <summary>
+		/// Adds an entry into the legend.  Duplicate entries are permissable
+		/// </summary>
+		/// <param name="graphCellToRender">The symbol appearing on the graph that should appear in the legend</param>
+		/// <param name="text">Text to render on this line of the legend.  Will be truncated
+		/// if outside of Legend <see cref="Bounds"/></param>
+		public void AddEntry (GraphCellToRender graphCellToRender, string text)
+		{
+			entries.Add (Tuple.Create (graphCellToRender, text));
+		}
+	}
+
+	/// <summary>
+	/// Sequence of lines to connect points e.g. of a <see cref="ScatterSeries"/>
+	/// </summary>
+	public class PathAnnotation : IAnnotation {
+
+		/// <summary>
+		/// Points that should be connected.  Lines will be drawn between points in the order
+		/// they appear in the list
+		/// </summary>
+		public List<PointF> Points { get; set; } = new List<PointF> ();
+
+		/// <summary>
+		/// Color for the line that connects points
+		/// </summary>
+		public Attribute? LineColor { get; set; }
+
+		/// <summary>
+		/// The symbol that gets drawn along the line, defaults to '.'
+		/// </summary>
+		public Rune LineRune { get; set; } = new Rune ('.');
+
+		/// <summary>
+		/// True to add line before plotting series.  Defaults to false
+		/// </summary>
+		public bool BeforeSeries { get; set; }
+
+
+		/// <summary>
+		/// Draws lines connecting each of the <see cref="Points"/>
+		/// </summary>
+		/// <param name="graph"></param>
+		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);
+			}
+		}
+
+		/// <summary>
+		/// Generates lines joining <see cref="Points"/> 
+		/// </summary>
+		/// <returns></returns>
+		private IEnumerable<LineF> PointsToLines ()
+		{
+			for (int i = 0; i < Points.Count - 1; i++) {
+				yield return new LineF (Points [i], Points [i + 1]);
+			}
+		}
+
+		/// <summary>
+		/// Describes two points in graph space and a line between them
+		/// </summary>
+		public class LineF {
+			/// <summary>
+			/// The start of the line
+			/// </summary>
+			public PointF Start { get; }
+
+			/// <summary>
+			/// The end point of the line
+			/// </summary>
+			public PointF End { get; }
+
+			/// <summary>
+			/// Creates a new line between the points
+			/// </summary>
+			public LineF (PointF start, PointF end)
+			{
+				this.Start = start;
+				this.End = end;
+			}
+		}
+	}
+}

+ 565 - 0
Terminal.Gui/Core/Graphs/Axis.cs

@@ -0,0 +1,565 @@
+using System;
+using System.Collections.Generic;
+
+namespace Terminal.Gui.Graphs {
+
+	/// <summary>
+	/// Renders a continuous line with grid line ticks and labels
+	/// </summary>
+	public abstract class Axis {
+		/// <summary>
+		/// Default value for <see cref="ShowLabelsEvery"/>
+		/// </summary>
+		const uint DefaultShowLabelsEvery = 5;
+
+		/// <summary>
+		/// Direction of the axis
+		/// </summary>
+		/// <value></value>
+		public Orientation Orientation { get; }
+
+		/// <summary>
+		/// Number of units of graph space between ticks on axis. 0 for no ticks
+		/// </summary>
+		/// <value></value>
+		public float Increment { get; set; } = 1;
+
+		/// <summary>
+		/// The number of <see cref="Increment"/> before an label is added.
+		/// 0 = never show labels
+		/// </summary>
+		public uint ShowLabelsEvery { get; set; } = DefaultShowLabelsEvery;
+
+		/// <summary>
+		/// True to render axis.  Defaults to true
+		/// </summary>
+		public bool Visible { get; set; } = true;
+
+		/// <summary>
+		/// Allows you to control what label text is rendered for a given <see cref="Increment"/>
+		/// when <see cref="ShowLabelsEvery"/> is above 0
+		/// </summary>
+		public LabelGetterDelegate LabelGetter;
+
+		/// <summary>
+		/// Displayed below/to left of labels (see <see cref="Orientation"/>).
+		/// If text is not visible, check <see cref="GraphView.MarginBottom"/> / <see cref="GraphView.MarginLeft"/>
+		/// </summary>
+		public string Text;
+
+		/// <summary>
+		/// The minimum axis point to show.  Defaults to null (no minimum)
+		/// </summary>
+		public float? Minimum { get; set; }
+
+		/// <summary>
+		/// Populates base properties and sets the read only <see cref="Orientation"/>
+		/// </summary>
+		/// <param name="orientation"></param>
+		protected Axis (Orientation orientation)
+		{
+			Orientation = orientation;
+			LabelGetter = DefaultLabelGetter;
+		}
+
+		/// <summary>
+		/// Draws the solid line of the axis
+		/// </summary>
+		/// <param name="graph"></param>
+		public abstract void DrawAxisLine (GraphView graph);
+
+		/// <summary>
+		/// Draws a single cell of the solid line of the axis
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="x"></param>
+		/// <param name="y"></param>
+		protected abstract void DrawAxisLine (GraphView graph, int x, int y);
+
+		/// <summary>
+		/// Draws labels and axis <see cref="Increment"/> ticks
+		/// </summary>
+		/// <param name="graph"></param>
+
+		public abstract void DrawAxisLabels (GraphView graph);
+
+		/// <summary>
+		/// Draws a custom label <paramref name="text"/> at <paramref name="screenPosition"/> units
+		/// along the axis (X or Y depending on <see cref="Orientation"/>)
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="screenPosition"></param>
+		/// <param name="text"></param>
+		public abstract void DrawAxisLabel (GraphView graph, int screenPosition, string text);
+
+		/// <summary>
+		/// Resets all configurable properties of the axis to default values
+		/// </summary>
+		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");
+		}
+	}
+
+	/// <summary>
+	/// The horizontal (x axis) of a <see cref="GraphView"/>
+	/// </summary>
+	public class HorizontalAxis : Axis {
+
+		/// <summary>
+		/// Creates a new instance of axis with an <see cref="Orientation"/> of <see cref="Orientation.Horizontal"/>
+		/// </summary>
+		public HorizontalAxis () : base (Orientation.Horizontal)
+		{
+		}
+
+
+		/// <summary>
+		/// Draws the horizontal axis line
+		/// </summary>
+		/// <param name="graph"></param>
+		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);
+			}
+		}
+
+
+		/// <summary>
+		/// Draws a horizontal axis line at the given <paramref name="x"/>, <paramref name="y"/> 
+		/// screen coordinates
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="x"></param>
+		/// <param name="y"></param>
+		protected override void DrawAxisLine (GraphView graph, int x, int y)
+		{
+			graph.Move (x, y);
+			Application.Driver.AddRune (Application.Driver.HLine);
+		}
+
+		/// <summary>
+		/// Draws the horizontal x axis labels and <see cref="Axis.Increment"/> ticks
+		/// </summary>
+		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);
+			}
+		}
+
+		/// <summary>
+		/// Draws the given <paramref name="text"/> on the axis at x <paramref name="screenPosition"/>.
+		/// For the screen y position use <see cref="GetAxisYPosition(GraphView)"/>
+		/// </summary>
+		/// <param name="graph">Graph being drawn onto</param>
+		/// <param name="screenPosition">Number of screen columns along the axis to take before rendering</param>
+		/// <param name="text">Text to render under the axis tick</param>
+		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<AxisIncrementToRender> 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;
+			}
+		}
+		/// <summary>
+		/// 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.
+		/// </summary>
+		/// <param name="graph"></param>
+		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));
+		}
+	}
+
+	/// <summary>
+	/// The vertical (i.e. Y axis) of a <see cref="GraphView"/>
+	/// </summary>
+	public class VerticalAxis : Axis {
+
+
+		/// <summary>
+		/// Creates a new <see cref="Orientation.Vertical"/> axis
+		/// </summary>
+		public VerticalAxis () : base (Orientation.Vertical)
+		{
+		}
+
+		/// <summary>
+		/// Draws the vertical axis line
+		/// </summary>
+		/// <param name="graph"></param>
+		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);
+			}
+		}
+
+		/// <summary>
+		/// Draws a vertical axis line at the given <paramref name="x"/>, <paramref name="y"/> 
+		/// screen coordinates
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="x"></param>
+		/// <param name="y"></param>
+		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;
+		}
+
+
+		/// <summary>
+		/// Draws axis <see cref="Axis.Increment"/> markers and labels
+		/// </summary>
+		/// <param name="graph"></param>
+		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<AxisIncrementToRender> 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;
+			}
+		}
+
+		/// <summary>
+		/// Draws the given <paramref name="text"/> on the axis at y <paramref name="screenPosition"/>.
+		/// For the screen x position use <see cref="GetAxisXPosition(GraphView)"/>
+		/// </summary>
+		/// <param name="graph">Graph being drawn onto</param>
+		/// <param name="screenPosition">Number of rows from the top of the screen (i.e. down the axis) before rendering</param>
+		/// <param name="text">Text to render to the left of the axis tick.  Ensure to 
+		/// set <see cref="GraphView.MarginLeft"/> or <see cref="GraphView.ScrollOffset"/> sufficient that it is visible</param>
+		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);
+			}
+		}
+
+		/// <summary>
+		/// 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.
+		/// </summary>
+		/// <param name="graph"></param>
+		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);
+		}
+	}
+
+
+	/// <summary>
+	/// A location on an axis of a <see cref="GraphView"/> that may
+	/// or may not have a label associated with it
+	/// </summary>
+	public class AxisIncrementToRender {
+
+		/// <summary>
+		/// Direction of the parent axis
+		/// </summary>
+		public Orientation Orientation { get; }
+
+		/// <summary>
+		/// The screen location (X or Y depending on <see cref="Orientation"/>) that the
+		/// increment will be rendered at
+		/// </summary>
+		public int ScreenLocation { get; }
+
+		/// <summary>
+		/// The value at this position on the axis in graph space
+		/// </summary>
+		public float Value { get; }
+
+		private string _text = "";
+
+		/// <summary>
+		/// The text (if any) that should be displayed at this axis increment
+		/// </summary>
+		/// <value></value>
+		internal string Text {
+			get => _text;
+			set { _text = value ?? ""; }
+		}
+
+		/// <summary>
+		/// Describe a new section of an axis that requires an axis increment
+		/// symbol and/or label
+		/// </summary>
+		/// <param name="orientation"></param>
+		/// <param name="screen"></param>
+		/// <param name="value"></param>
+		public AxisIncrementToRender (Orientation orientation, int screen, float value)
+		{
+			Orientation = orientation;
+			ScreenLocation = screen;
+			Value = value;
+		}
+	}
+
+	/// <summary>
+	/// Delegate for custom formatting of axis labels.  Determines what should be displayed at a given label
+	/// </summary>
+	/// <param name="toRender">The axis increment to which the label is attached</param>
+	/// <returns></returns>
+	public delegate string LabelGetterDelegate (AxisIncrementToRender toRender);
+
+}

+ 45 - 0
Terminal.Gui/Core/Graphs/GraphCellToRender.cs

@@ -0,0 +1,45 @@
+using System;
+
+namespace Terminal.Gui.Graphs {
+	/// <summary>
+	/// Describes how to render a single row/column of a <see cref="GraphView"/> based
+	/// on the value(s) in <see cref="ISeries"/> at that location
+	/// </summary>
+	public class GraphCellToRender {
+
+		/// <summary>
+		/// The character to render in the console
+		/// </summary>
+		public Rune Rune { get; set; }
+
+		/// <summary>
+		/// Optional color to render the <see cref="Rune"/> with
+		/// </summary>
+		public Attribute? Color { get; set; }
+
+		/// <summary>
+		/// Creates instance and sets <see cref="Rune"/> with default graph coloring
+		/// </summary>
+		/// <param name="rune"></param>
+		public GraphCellToRender (Rune rune)
+		{
+			Rune = rune;
+		}
+		/// <summary>
+		/// Creates instance and sets <see cref="Rune"/> with custom graph coloring
+		/// </summary>
+		/// <param name="rune"></param>
+		/// <param name="color"></param>
+		public GraphCellToRender (Rune rune, Attribute color) : this (rune)
+		{
+			Color = color;
+		}
+		/// <summary>
+		/// Creates instance and sets <see cref="Rune"/> and <see cref="Color"/> (or default if null)
+		/// </summary>
+		public GraphCellToRender (Rune rune, Attribute? color) : this (rune)
+		{
+			Color = color;
+		}
+	}
+}

+ 17 - 0
Terminal.Gui/Core/Graphs/Orientation.cs

@@ -0,0 +1,17 @@
+namespace Terminal.Gui.Graphs {
+	/// <summary>
+	/// Direction of an element (horizontal or vertical)
+	/// </summary>
+	public enum Orientation {
+
+		/// <summary>
+		/// Left to right 
+		/// </summary>
+		Horizontal,
+
+		/// <summary>
+		/// Bottom to top
+		/// </summary>
+		Vertical
+	}
+}

+ 323 - 0
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 {
+	/// <summary>
+	/// Describes a series of data that can be rendered into a <see cref="GraphView"/>>
+	/// </summary>
+	public interface ISeries {
+
+		/// <summary>
+		/// Draws the <paramref name="graphBounds"/> section of a series into the
+		/// <paramref name="graph"/> view <paramref name="drawBounds"/>
+		/// </summary>
+		/// <param name="graph">Graph series is to be drawn onto</param>
+		/// <param name="drawBounds">Visible area of the graph in Console Screen units (excluding margins)</param>
+		/// <param name="graphBounds">Visible area of the graph in Graph space units</param>
+		void DrawSeries (GraphView graph, Rect drawBounds, RectangleF graphBounds);
+	}
+
+
+	/// <summary>
+	/// Series composed of any number of discrete data points 
+	/// </summary>
+	public class ScatterSeries : ISeries {
+		/// <summary>
+		/// Collection of each discrete point in the series
+		/// </summary>
+		/// <returns></returns>
+		public List<PointF> Points { get; set; } = new List<PointF> ();
+
+		/// <summary>
+		/// 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'
+		/// </summary>
+		public GraphCellToRender Fill { get; set; } = new GraphCellToRender ('x');
+
+		/// <summary>
+		/// Draws all points directly onto the graph
+		/// </summary>
+		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);
+			}
+
+		}
+
+	}
+
+
+	/// <summary>
+	/// Collection of <see cref="BarSeries"/> in which bars are clustered by category
+	/// </summary>
+	public class MultiBarSeries : ISeries {
+
+		BarSeries [] subSeries;
+
+		/// <summary>
+		/// 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
+		/// </summary>
+		public IReadOnlyCollection<BarSeries> SubSeries { get => new ReadOnlyCollection<BarSeries> (subSeries); }
+
+		/// <summary>
+		/// The number of units of graph space between bars.  Should be 
+		/// less than <see cref="BarSeries.BarEvery"/>
+		/// </summary>
+		public float Spacing { get; }
+
+		/// <summary>
+		/// Creates a new series of clustered bars.
+		/// </summary>
+		/// <param name="numberOfBarsPerCategory">Each category has this many bars</param>
+		/// <param name="barsEvery">How far appart to put each category (in graph space)</param>
+		/// <param name="spacing">How much spacing between bars in a category (should be less than <paramref name="barsEvery"/>/<paramref name="numberOfBarsPerCategory"/>)</param>
+		/// <param name="colors">Array of colors that define bar color in each category.  Length must match <paramref name="numberOfBarsPerCategory"/></param>
+		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;
+		}
+
+		/// <summary>
+		/// Adds a new cluster of bars
+		/// </summary>
+		/// <param name="label"></param>
+		/// <param name="fill"></param>
+		/// <param name="values">Values for each bar in category, must match the number of bars per category</param>
+		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]));
+			}
+		}
+
+		/// <summary>
+		/// Draws all <see cref="SubSeries"/>
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="drawBounds"></param>
+		/// <param name="graphBounds"></param>
+		public void DrawSeries (GraphView graph, Rect drawBounds, RectangleF graphBounds)
+		{
+			foreach (var bar in subSeries) {
+				bar.DrawSeries (graph, drawBounds, graphBounds);
+			}
+
+		}
+	}
+
+	/// <summary>
+	/// Series of bars positioned at regular intervals
+	/// </summary>
+	public class BarSeries : ISeries {
+
+		/// <summary>
+		/// Ordered collection of graph bars to position along axis
+		/// </summary>
+		public List<Bar> Bars { get; set; } = new List<Bar> ();
+
+		/// <summary>
+		/// 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 <see cref="GraphView.CellSize"/> when changing this.
+		/// </summary>
+		public float BarEvery { get; set; } = 1;
+
+		/// <summary>
+		/// Direction bars protrude from the corresponding axis.
+		/// Defaults to vertical
+		/// </summary>
+		public Orientation Orientation { get; set; } = Orientation.Vertical;
+
+		/// <summary>
+		/// The number of units of graph space along the axis before rendering the first bar
+		/// (and subsequent bars - see <see cref="BarEvery"/>).  Defaults to 0
+		/// </summary>
+		public float Offset { get; set; } = 0;
+
+		/// <summary>
+		/// Overrides the <see cref="Bar.Fill"/> with a fixed color
+		/// </summary>
+		public Attribute? OverrideBarColor { get; set; }
+
+		/// <summary>
+		/// True to draw <see cref="Bar.Text"/> along the axis under the bar.  Defaults
+		/// to true.
+		/// </summary>
+		public bool DrawLabels { get; set; } = true;
+
+		/// <summary>
+		/// Applies any color overriding
+		/// </summary>
+		/// <param name="graphCellToRender"></param>
+		/// <returns></returns>
+		protected virtual GraphCellToRender AdjustColor (GraphCellToRender graphCellToRender)
+		{
+			if (OverrideBarColor.HasValue) {
+				graphCellToRender.Color = OverrideBarColor;
+			}
+
+			return graphCellToRender;
+		}
+
+		/// <summary>
+		/// Draws bars that are currently in the <paramref name="drawBounds"/>
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="drawBounds">Screen area of the graph excluding margins</param>
+		/// <param name="graphBounds">Graph space area that should be drawn into <paramref name="drawBounds"/></param>
+		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);
+					}
+				}
+			}
+
+		}
+
+		/// <summary>
+		/// Override to do custom drawing of the bar e.g. to apply varying color or changing the fill
+		/// symbol mid bar.
+		/// </summary>
+		/// <param name="graph"></param>
+		/// <param name="start">Screen position of the start of the bar</param>
+		/// <param name="end">Screen position of the end of the bar</param>
+		/// <param name="beingDrawn">The Bar that occupies this space and is being drawn</param>
+		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 ();
+		}
+
+		/// <summary>
+		/// A single bar in a <see cref="BarSeries"/>
+		/// </summary>
+		public class Bar {
+
+			/// <summary>
+			/// Optional text that describes the bar.  This will be rendered on the corresponding
+			/// <see cref="Axis"/> unless <see cref="DrawLabels"/> is false
+			/// </summary>
+			public string Text { get; set; }
+
+			/// <summary>
+			/// The color and character that will be rendered in the console
+			/// when the bar extends over it
+			/// </summary>
+			public GraphCellToRender Fill { get; set; }
+
+			/// <summary>
+			/// The value in graph space X/Y (depending on <see cref="Orientation"/>) to which the bar extends.
+			/// </summary>
+			public float Value { get; }
+
+			/// <summary>
+			/// Creates a new instance of a single bar rendered in the given <paramref name="fill"/> that extends
+			/// out <paramref name="value"/> graph space units in the default <see cref="Orientation"/>
+			/// </summary>
+			/// <param name="text"></param>
+			/// <param name="fill"></param>
+			/// <param name="value"></param>
+			public Bar (string text, GraphCellToRender fill, float value)
+			{
+				Text = text;
+				Fill = fill;
+				Value = value;
+			}
+		}
+	}
+}

+ 138 - 0
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 {
+	/// <summary>
+	/// Represents an ordered pair of x and y coordinates that define a point in a two-dimensional plane.
+	/// </summary>
+	public struct PointF : IEquatable<PointF> {
+		/// <summary>
+		/// Creates a new instance of the <see cref='Terminal.Gui.PointF'/> class with member data left uninitialized.
+		/// </summary>
+		public static readonly PointF Empty;
+		private float x; // Do not rename (binary serialization)
+		private float y; // Do not rename (binary serialization)
+
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.PointF'/> class with the specified coordinates.
+		/// </summary>
+		public PointF (float x, float y)
+		{
+			this.x = x;
+			this.y = y;
+		}
+
+		/// <summary>
+		/// Gets a value indicating whether this <see cref='Terminal.Gui.PointF'/> is empty.
+		/// </summary>
+		[Browsable (false)]
+		public bool IsEmpty => x == 0f && y == 0f;
+
+		/// <summary>
+		/// Gets the x-coordinate of this <see cref='Terminal.Gui.PointF'/>.
+		/// </summary>
+		public float X {
+			get => x;
+			set => x = value;
+		}
+
+		/// <summary>
+		/// Gets the y-coordinate of this <see cref='Terminal.Gui.PointF'/>.
+		/// </summary>
+		public float Y {
+			get => y;
+			set => y = value;
+		}
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by a given <see cref='Terminal.Gui.Size'/> .
+		/// </summary>
+		public static PointF operator + (PointF pt, Size sz) => Add (pt, sz);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by the negative of a given <see cref='Terminal.Gui.Size'/> .
+		/// </summary>
+		public static PointF operator - (PointF pt, Size sz) => Subtract (pt, sz);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by a given <see cref='Terminal.Gui.SizeF'/> .
+		/// </summary>
+		public static PointF operator + (PointF pt, SizeF sz) => Add (pt, sz);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by the negative of a given <see cref='Terminal.Gui.SizeF'/> .
+		/// </summary>
+		public static PointF operator - (PointF pt, SizeF sz) => Subtract (pt, sz);
+
+		/// <summary>
+		/// Compares two <see cref='Terminal.Gui.PointF'/> objects. The result specifies whether the values of the
+		/// <see cref='Terminal.Gui.PointF.X'/> and <see cref='Terminal.Gui.PointF.Y'/> properties of the two
+		/// <see cref='Terminal.Gui.PointF'/> objects are equal.
+		/// </summary>
+		public static bool operator == (PointF left, PointF right) => left.X == right.X && left.Y == right.Y;
+
+		/// <summary>
+		/// Compares two <see cref='Terminal.Gui.PointF'/> objects. The result specifies whether the values of the
+		/// <see cref='Terminal.Gui.PointF.X'/> or <see cref='Terminal.Gui.PointF.Y'/> properties of the two
+		/// <see cref='Terminal.Gui.PointF'/> objects are unequal.
+		/// </summary>
+		public static bool operator != (PointF left, PointF right) => !(left == right);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by a given <see cref='Terminal.Gui.Size'/> .
+		/// </summary>
+		public static PointF Add (PointF pt, Size sz) => new PointF (pt.X + sz.Width, pt.Y + sz.Height);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by the negative of a given <see cref='Terminal.Gui.Size'/> .
+		/// </summary>
+		public static PointF Subtract (PointF pt, Size sz) => new PointF (pt.X - sz.Width, pt.Y - sz.Height);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by a given <see cref='Terminal.Gui.SizeF'/> .
+		/// </summary>
+		public static PointF Add (PointF pt, SizeF sz) => new PointF (pt.X + sz.Width, pt.Y + sz.Height);
+
+		/// <summary>
+		/// Translates a <see cref='Terminal.Gui.PointF'/> by the negative of a given <see cref='Terminal.Gui.SizeF'/> .
+		/// </summary>
+		public static PointF Subtract (PointF pt, SizeF sz) => new PointF (pt.X - sz.Width, pt.Y - sz.Height);
+
+
+		/// <summary>
+		/// Compares two <see cref='Terminal.Gui.PointF'/> objects. The result specifies whether the values of the
+		/// <see cref='Terminal.Gui.PointF.X'/> and <see cref='Terminal.Gui.PointF.Y'/> properties of the two
+		/// <see cref='Terminal.Gui.PointF'/> objects are equal.
+		/// </summary>
+		public override bool Equals (object obj) => obj is PointF && Equals ((PointF)obj);
+
+
+		/// <summary>
+		/// Compares two <see cref='Terminal.Gui.PointF'/> objects. The result specifies whether the values of the
+		/// <see cref='Terminal.Gui.PointF.X'/> and <see cref='Terminal.Gui.PointF.Y'/> properties of the two
+		/// <see cref='Terminal.Gui.PointF'/> objects are equal.
+		/// </summary>
+		public bool Equals (PointF other) => this == other;
+
+		/// <summary>
+		/// Generates a hashcode from the X and Y components
+		/// </summary>
+		/// <returns></returns>
+		public override int GetHashCode ()
+		{
+			return X.GetHashCode() ^ Y.GetHashCode ();
+		}
+
+		/// <summary>
+		/// Returns a string including the X and Y values
+		/// </summary>
+		/// <returns></returns>
+		public override string ToString () => "{X=" + x.ToString () + ", Y=" + y.ToString () + "}";
+	}
+}

+ 303 - 0
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 {
+	/// <summary>
+	/// Stores the location and size of a rectangular region.
+	/// </summary>
+	public struct RectangleF : IEquatable<RectangleF> {
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.RectangleF'/> class.
+		/// </summary>
+		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)
+
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.RectangleF'/> class with the specified location
+		/// and size.
+		/// </summary>
+		public RectangleF (float x, float y, float width, float height)
+		{
+			this.x = x;
+			this.y = y;
+			this.width = width;
+			this.height = height;
+		}
+
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.RectangleF'/> class with the specified location
+		/// and size.
+		/// </summary>
+		public RectangleF (PointF location, SizeF size)
+		{
+			x = location.X;
+			y = location.Y;
+			width = size.Width;
+			height = size.Height;
+		}
+
+		/// <summary>
+		/// Creates a new <see cref='Terminal.Gui.RectangleF'/> with the specified location and size.
+		/// </summary>
+		public static RectangleF FromLTRB (float left, float top, float right, float bottom) =>
+		    new RectangleF (left, top, right - left, bottom - top);
+
+		/// <summary>
+		/// Gets or sets the coordinates of the upper-left corner of the rectangular region represented by this
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		[Browsable (false)]
+		public PointF Location {
+			get => new PointF (X, Y);
+			set {
+				X = value.X;
+				Y = value.Y;
+			}
+		}
+
+		/// <summary>
+		/// Gets or sets the size of this <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		[Browsable (false)]
+		public SizeF Size {
+			get => new SizeF (Width, Height);
+			set {
+				Width = value.Width;
+				Height = value.Height;
+			}
+		}
+
+		/// <summary>
+		/// Gets or sets the x-coordinate of the upper-left corner of the rectangular region defined by this
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public float X {
+			get => x;
+			set => x = value;
+		}
+
+		/// <summary>
+		/// Gets or sets the y-coordinate of the upper-left corner of the rectangular region defined by this
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public float Y {
+			get => y;
+			set => y = value;
+		}
+
+		/// <summary>
+		/// Gets or sets the width of the rectangular region defined by this <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public float Width {
+			get => width;
+			set => width = value;
+		}
+
+		/// <summary>
+		/// Gets or sets the height of the rectangular region defined by this <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public float Height {
+			get => height;
+			set => height = value;
+		}
+
+		/// <summary>
+		/// Gets the x-coordinate of the upper-left corner of the rectangular region defined by this
+		/// <see cref='Terminal.Gui.RectangleF'/> .
+		/// </summary>
+		[Browsable (false)]
+		public float Left => X;
+
+		/// <summary>
+		/// Gets the y-coordinate of the upper-left corner of the rectangular region defined by this
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		[Browsable (false)]
+		public float Top => Y;
+
+		/// <summary>
+		/// Gets the x-coordinate of the lower-right corner of the rectangular region defined by this
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		[Browsable (false)]
+		public float Right => X + Width;
+
+		/// <summary>
+		/// Gets the y-coordinate of the lower-right corner of the rectangular region defined by this
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		[Browsable (false)]
+		public float Bottom => Y + Height;
+
+		/// <summary>
+		/// Tests whether this <see cref='Terminal.Gui.RectangleF'/> has a <see cref='Terminal.Gui.RectangleF.Width'/> or a <see cref='Terminal.Gui.RectangleF.Height'/> of 0.
+		/// </summary>
+		[Browsable (false)]
+		public bool IsEmpty => (Width <= 0) || (Height <= 0);
+
+		/// <summary>
+		/// Tests whether <paramref name="obj"/> is a <see cref='Terminal.Gui.RectangleF'/> with the same location and
+		/// size of this <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public override bool Equals (object obj) => obj is RectangleF && Equals ((RectangleF)obj);
+
+		/// <summary>
+		/// Returns true if two <see cref='Terminal.Gui.RectangleF'/> objects have equal location and size.
+		/// </summary>
+		/// <param name="other"></param>
+		/// <returns></returns>
+		public bool Equals (RectangleF other) => this == other;
+
+		/// <summary>
+		/// Tests whether two <see cref='Terminal.Gui.RectangleF'/> objects have equal location and size.
+		/// </summary>
+		public static bool operator == (RectangleF left, RectangleF right) =>
+		    left.X == right.X && left.Y == right.Y && left.Width == right.Width && left.Height == right.Height;
+
+		/// <summary>
+		/// Tests whether two <see cref='Terminal.Gui.RectangleF'/> objects differ in location or size.
+		/// </summary>
+		public static bool operator != (RectangleF left, RectangleF right) => !(left == right);
+
+		/// <summary>
+		/// Determines if the specified point is contained within the rectangular region defined by this
+		/// <see cref='Terminal.Gui.Rect'/> .
+		/// </summary>
+		public bool Contains (float x, float y) => X <= x && x < X + Width && Y <= y && y < Y + Height;
+
+		/// <summary>
+		/// Determines if the specified point is contained within the rectangular region defined by this
+		/// <see cref='Terminal.Gui.Rect'/> .
+		/// </summary>
+		public bool Contains (PointF pt) => Contains (pt.X, pt.Y);
+
+		/// <summary>
+		/// Determines if the rectangular region represented by <paramref name="rect"/> is entirely contained within
+		/// the rectangular region represented by this <see cref='Terminal.Gui.Rect'/> .
+		/// </summary>
+		public bool Contains (RectangleF rect) =>
+		    (X <= rect.X) && (rect.X + rect.Width <= X + Width) && (Y <= rect.Y) && (rect.Y + rect.Height <= Y + Height);
+
+		/// <summary>
+		/// Gets the hash code for this <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public override int GetHashCode ()
+		{
+			return (Height.GetHashCode () + Width.GetHashCode ()) ^ X.GetHashCode () + Y.GetHashCode ();
+		}
+
+		/// <summary>
+		/// Inflates this <see cref='Terminal.Gui.Rect'/> by the specified amount.
+		/// </summary>
+		public void Inflate (float x, float y)
+		{
+			X -= x;
+			Y -= y;
+			Width += 2 * x;
+			Height += 2 * y;
+		}
+
+		/// <summary>
+		/// Inflates this <see cref='Terminal.Gui.Rect'/> by the specified amount.
+		/// </summary>
+		public void Inflate (SizeF size) => Inflate (size.Width, size.Height);
+
+		/// <summary>
+		/// Creates a <see cref='Terminal.Gui.Rect'/> that is inflated by the specified amount.
+		/// </summary>
+		public static RectangleF Inflate (RectangleF rect, float x, float y)
+		{
+			RectangleF r = rect;
+			r.Inflate (x, y);
+			return r;
+		}
+
+		/// <summary>
+		/// Creates a Rectangle that represents the intersection between this Rectangle and rect.
+		/// </summary>
+		public void Intersect (RectangleF rect)
+		{
+			RectangleF result = Intersect (rect, this);
+
+			X = result.X;
+			Y = result.Y;
+			Width = result.Width;
+			Height = result.Height;
+		}
+
+		/// <summary>
+		/// Creates a rectangle that represents the intersection between a and b. If there is no intersection, an
+		/// empty rectangle is returned.
+		/// </summary>
+		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;
+		}
+
+		/// <summary>
+		/// Determines if this rectangle intersects with rect.
+		/// </summary>
+		public bool IntersectsWith (RectangleF rect) =>
+		    (rect.X < X + Width) && (X < rect.X + rect.Width) && (rect.Y < Y + Height) && (Y < rect.Y + rect.Height);
+
+		/// <summary>
+		/// Creates a rectangle that represents the union between a and b.
+		/// </summary>
+		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);
+		}
+
+		/// <summary>
+		/// Adjusts the location of this rectangle by the specified amount.
+		/// </summary>
+		public void Offset (PointF pos) => Offset (pos.X, pos.Y);
+
+		/// <summary>
+		/// Adjusts the location of this rectangle by the specified amount.
+		/// </summary>
+		public void Offset (float x, float y)
+		{
+			X += x;
+			Y += y;
+		}
+
+		/// <summary>
+		/// Converts the specified <see cref='Terminal.Gui.Rect'/> to a
+		/// <see cref='Terminal.Gui.RectangleF'/>.
+		/// </summary>
+		public static implicit operator RectangleF (Rect r) => new RectangleF (r.X, r.Y, r.Width, r.Height);
+
+		/// <summary>
+		/// Converts the <see cref='Terminal.Gui.RectangleF.Location'/> and <see cref='Terminal.Gui.RectangleF.Size'/>
+		/// of this <see cref='Terminal.Gui.RectangleF'/> to a human-readable string.
+		/// </summary>
+		public override string ToString () =>
+		    "{X=" + X.ToString () + ",Y=" + Y.ToString () +
+		    ",Width=" + Width.ToString () + ",Height=" + Height.ToString () + "}";
+	}
+}

+ 168 - 0
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 {
+	/// <summary>
+	/// Represents the size of a rectangular region with an ordered pair of width and height.
+	/// </summary>
+	public struct SizeF : IEquatable<SizeF> {
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.SizeF'/> class.
+		/// </summary>
+		public static readonly SizeF Empty;
+		private float width; // Do not rename (binary serialization)
+		private float height; // Do not rename (binary serialization)
+
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.SizeF'/> class from the specified
+		/// existing <see cref='Terminal.Gui.SizeF'/>.
+		/// </summary>
+		public SizeF (SizeF size)
+		{
+			width = size.width;
+			height = size.height;
+		}
+
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.SizeF'/> class from the specified
+		/// <see cref='Terminal.Gui.PointF'/>.
+		/// </summary>
+		public SizeF (PointF pt)
+		{
+			width = pt.X;
+			height = pt.Y;
+		}
+
+		/// <summary>
+		/// Initializes a new instance of the <see cref='Terminal.Gui.SizeF'/> class from the specified dimensions.
+		/// </summary>
+		public SizeF (float width, float height)
+		{
+			this.width = width;
+			this.height = height;
+		}
+
+		/// <summary>
+		/// Performs vector addition of two <see cref='Terminal.Gui.SizeF'/> objects.
+		/// </summary>
+		public static SizeF operator + (SizeF sz1, SizeF sz2) => Add (sz1, sz2);
+
+		/// <summary>
+		/// Contracts a <see cref='Terminal.Gui.SizeF'/> by another <see cref='Terminal.Gui.SizeF'/>
+		/// </summary>
+		public static SizeF operator - (SizeF sz1, SizeF sz2) => Subtract (sz1, sz2);
+
+		/// <summary>
+		/// Multiplies <see cref="SizeF"/> by a <see cref="float"/> producing <see cref="SizeF"/>.
+		/// </summary>
+		/// <param name="left">Multiplier of type <see cref="float"/>.</param>
+		/// <param name="right">Multiplicand of type <see cref="SizeF"/>.</param>
+		/// <returns>Product of type <see cref="SizeF"/>.</returns>
+		public static SizeF operator * (float left, SizeF right) => Multiply (right, left);
+
+		/// <summary>
+		/// Multiplies <see cref="SizeF"/> by a <see cref="float"/> producing <see cref="SizeF"/>.
+		/// </summary>
+		/// <param name="left">Multiplicand of type <see cref="SizeF"/>.</param>
+		/// <param name="right">Multiplier of type <see cref="float"/>.</param>
+		/// <returns>Product of type <see cref="SizeF"/>.</returns>
+		public static SizeF operator * (SizeF left, float right) => Multiply (left, right);
+
+		/// <summary>
+		/// Divides <see cref="SizeF"/> by a <see cref="float"/> producing <see cref="SizeF"/>.
+		/// </summary>
+		/// <param name="left">Dividend of type <see cref="SizeF"/>.</param>
+		/// <param name="right">Divisor of type <see cref="int"/>.</param>
+		/// <returns>Result of type <see cref="SizeF"/>.</returns>
+		public static SizeF operator / (SizeF left, float right)
+		    => new SizeF (left.width / right, left.height / right);
+
+		/// <summary>
+		/// Tests whether two <see cref='Terminal.Gui.SizeF'/> objects are identical.
+		/// </summary>
+		public static bool operator == (SizeF sz1, SizeF sz2) => sz1.Width == sz2.Width && sz1.Height == sz2.Height;
+
+		/// <summary>
+		/// Tests whether two <see cref='Terminal.Gui.SizeF'/> objects are different.
+		/// </summary>
+		public static bool operator != (SizeF sz1, SizeF sz2) => !(sz1 == sz2);
+
+		/// <summary>
+		/// Converts the specified <see cref='Terminal.Gui.SizeF'/> to a <see cref='Terminal.Gui.PointF'/>.
+		/// </summary>
+		public static explicit operator PointF (SizeF size) => new PointF (size.Width, size.Height);
+
+		/// <summary>
+		/// Tests whether this <see cref='Terminal.Gui.SizeF'/> has zero width and height.
+		/// </summary>
+		[Browsable (false)]
+		public bool IsEmpty => width == 0 && height == 0;
+
+		/// <summary>
+		/// Represents the horizontal component of this <see cref='Terminal.Gui.SizeF'/>.
+		/// </summary>
+		public float Width {
+			get => width;
+			set => width = value;
+		}
+
+		/// <summary>
+		/// Represents the vertical component of this <see cref='Terminal.Gui.SizeF'/>.
+		/// </summary>
+		public float Height {
+			get => height;
+			set => height = value;
+		}
+
+		/// <summary>
+		/// Performs vector addition of two <see cref='Terminal.Gui.SizeF'/> objects.
+		/// </summary>
+		public static SizeF Add (SizeF sz1, SizeF sz2) => new SizeF (sz1.Width + sz2.Width, sz1.Height + sz2.Height);
+
+		/// <summary>
+		/// Contracts a <see cref='Terminal.Gui.SizeF'/> by another <see cref='Terminal.Gui.SizeF'/>.
+		/// </summary>
+		public static SizeF Subtract (SizeF sz1, SizeF sz2) => new SizeF (sz1.Width - sz2.Width, sz1.Height - sz2.Height);
+
+		/// <summary>
+		/// Tests to see whether the specified object is a <see cref='Terminal.Gui.SizeF'/>  with the same dimensions
+		/// as this <see cref='Terminal.Gui.SizeF'/>.
+		/// </summary>
+		public override bool Equals (object obj) => obj is SizeF && Equals ((SizeF)obj);
+
+
+		/// <summary>
+		/// Tests whether two <see cref='Terminal.Gui.SizeF'/> objects are identical.
+		/// </summary>
+		public bool Equals (SizeF other) => this == other;
+
+		/// <summary>
+		/// Generates a hashcode from the width and height
+		/// </summary>
+		/// <returns></returns>
+		public override int GetHashCode ()
+		{
+			return width.GetHashCode() ^ height.GetHashCode ();
+		}
+		
+		/// <summary>
+		/// Creates a human-readable string that represents this <see cref='Terminal.Gui.SizeF'/>.
+		/// </summary>
+		public override string ToString () => "{Width=" + width.ToString () + ", Height=" + height.ToString () + "}";
+
+		/// <summary>
+		/// Multiplies <see cref="SizeF"/> by a <see cref="float"/> producing <see cref="SizeF"/>.
+		/// </summary>
+		/// <param name="size">Multiplicand of type <see cref="SizeF"/>.</param>
+		/// <param name="multiplier">Multiplier of type <see cref="float"/>.</param>
+		/// <returns>Product of type SizeF.</returns>
+		private static SizeF Multiply (SizeF size, float multiplier) =>
+		    new SizeF (size.width * multiplier, size.height * multiplier);
+	}
+}

+ 318 - 0
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 {
+
+	/// <summary>
+	/// Control for rendering graphs (bar, scatter etc)
+	/// </summary>
+	public class GraphView : View {
+
+		/// <summary>
+		/// Horizontal axis
+		/// </summary>
+		/// <value></value>
+		public HorizontalAxis AxisX { get; set; }
+
+		/// <summary>
+		/// Vertical axis
+		/// </summary>
+		/// <value></value>
+		public VerticalAxis AxisY { get; set; }
+
+		/// <summary>
+		/// Collection of data series that are rendered in the graph
+		/// </summary>
+		public List<ISeries> Series { get; } = new List<ISeries> ();
+
+
+		/// <summary>
+		/// Elements drawn into graph after series have been drawn e.g. Legends etc
+		/// </summary>
+		public List<IAnnotation> Annotations { get; } = new List<IAnnotation> ();
+
+		/// <summary>
+		/// Amount of space to leave on left of control.  Graph content (<see cref="Series"/>)
+		/// will not be rendered in margins but axis labels may be
+		/// </summary>
+		public uint MarginLeft { get; set; }
+
+		/// <summary>
+		/// Amount of space to leave on bottom of control.  Graph content (<see cref="Series"/>)
+		/// will not be rendered in margins but axis labels may be
+		/// </summary>
+		public uint MarginBottom { get; set; }
+
+		/// <summary>
+		/// The graph space position of the bottom left of the control.
+		/// Changing this scrolls the viewport around in the graph
+		/// </summary>
+		/// <value></value>
+		public PointF ScrollOffset { get; set; } = new PointF (0, 0);
+
+		/// <summary>
+		/// Translates console width/height into graph space. Defaults
+		/// to 1 row/col of console space being 1 unit of graph space. 
+		/// </summary>
+		/// <returns></returns>
+		public PointF CellSize { get; set; } = new PointF (1, 1);
+
+		/// <summary>
+		/// The color of the background of the graph and axis/labels
+		/// </summary>
+		public Attribute? GraphColor { get; set; }
+
+		/// <summary>
+		/// Creates a new graph with a 1 to 1 graph space with absolute layout
+		/// </summary>
+		public GraphView ()
+		{
+			CanFocus = true;
+
+			AxisX = new HorizontalAxis ();
+			AxisY = new VerticalAxis ();
+		}
+
+		/// <summary>
+		/// Clears all settings configured on the graph and resets all properties
+		/// to default values (<see cref="CellSize"/>, <see cref="ScrollOffset"/> etc) 
+		/// </summary>
+		public void Reset ()
+		{
+			ScrollOffset = new PointF (0, 0);
+			CellSize = new PointF (1, 1);
+			AxisX.Reset ();
+			AxisY.Reset ();
+			Series.Clear ();
+			Annotations.Clear ();
+			GraphColor = null;
+			SetNeedsDisplay ();
+		}
+
+		///<inheritdoc/>
+		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);
+			}
+
+		}
+
+		/// <summary>
+		/// Sets the color attribute of <see cref="Application.Driver"/> to the <see cref="GraphColor"/>
+		/// (if defined) or <see cref="ColorScheme"/> otherwise.
+		/// </summary>
+		public void SetDriverColorToGraphColor ()
+		{
+			Driver.SetAttribute (GraphColor ?? ColorScheme.Normal);
+		}
+
+		/// <summary>
+		/// Returns the section of the graph that is represented by the given
+		/// screen position
+		/// </summary>
+		/// <param name="col"></param>
+		/// <param name="row"></param>
+		/// <returns></returns>
+		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);
+		}
+
+
+		/// <summary>
+		/// Returns the section of the graph that is represented by the screen area
+		/// </summary>
+		/// <param name="screenArea"></param>
+		/// <returns></returns>
+		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);
+		}
+		/// <summary>
+		/// Calculates the screen location for a given point in graph space.
+		/// Bear in mind these be off screen
+		/// </summary>
+		/// <param name="location">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</param>
+		/// <returns>Screen position (Column/Row) which would be used to render the graph <paramref name="location"/>.
+		/// Note that this can be outside the current client area of the control</returns>
+		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)
+				);
+		}
+
+
+
+		/// <inheritdoc/>
+		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);
+		}
+
+		/// <summary>
+		/// Scrolls the view by a given number of units in graph space.
+		/// See <see cref="CellSize"/> to translate this into rows/cols
+		/// </summary>
+		/// <param name="offsetX"></param>
+		/// <param name="offsetY"></param>
+		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));
+		}
+
+		/// <summary>
+		/// Draws a line between two points in screen space.  Can be diagonals.
+		/// </summary>
+		/// <param name="start"></param>
+		/// <param name="end"></param>
+		/// <param name="symbol">The symbol to use for the line</param>
+		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
+	}
+}

+ 685 - 0
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<PointF> randomPoints = new List<PointF> ();
+
+			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<PointF> ();
+
+			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<BarSeries.Bar> () {
+					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<BarSeries.Bar> ()
+				{
+					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<BarSeries.Bar> ()
+				{
+					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<BarSeries.Bar> ();
+
+			Func<MainLoop, bool> 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<PointF>{
+						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 ();
+		}
+	}
+}

+ 1307 - 0
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<Point> DrawAxisLinePoints = new List<Point> ();
+		public List<int> LabelPoints = new List<int>();
+
+		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<Point> DrawAxisLinePoints = new List<Point> ();
+		public List<int> LabelPoints = new List<int>();
+
+		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;
+		}
+
+		/// <summary>
+		/// Returns a basic very small graph (10 x 5)
+		/// </summary>
+		/// <returns></returns>
+		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
+
+
+		/// <summary>
+		/// A cell size of 0 would result in mapping all graph space into the
+		/// same cell of the console.  Since <see cref="GraphView.CellSize"/>
+		/// is mutable a sensible place to check this is in redraw.
+		/// </summary>
+		[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<PointF> { new PointF (1, 1) } });
+			gv.CellSize= new PointF(0,5);
+			var ex = Assert.Throws<Exception>(()=>gv.Redraw (gv.Bounds));
+
+			Assert.Equal ("CellSize cannot be 0", ex.Message);
+		}
+
+		
+
+		/// <summary>
+		/// 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
+		/// </summary>
+		[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);
+		}
+
+		/// <summary>
+		/// Tests that the bounds passed to the ISeries for drawing into are 
+		/// correct even when the <see cref="GraphView.CellSize"/> results in
+		/// multiple units of graph space being condensed into each cell of
+		/// console
+		/// </summary>
+		[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<GraphView, Rect, RectangleF> drawSeries;
+
+			public FakeSeries (
+				Action<GraphView, Rect, RectangleF> 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<ArgumentException>(()=>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<ArgumentException>(()=>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<Point> BarScreenStarts { get; private set; } = new List<Point>();
+			public List<Point> BarScreenEnds { get; private set; } = new List<Point>();
+			
+			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
+
+		/// <summary>
+		/// Tests that the horizontal axis is computed correctly and does not over spill
+		/// it's bounds
+		/// </summary>
+		[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
+
+
+		/// <summary>
+		/// Tests that the horizontal axis is computed correctly and does not over spill
+		/// it's bounds
+		/// </summary>
+		[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);
+		}
+	}
+}

+ 1 - 0
UnitTests/TabViewTests.cs

@@ -8,6 +8,7 @@ using Xunit;
 using System.Globalization;
 
 namespace Terminal.Gui.Views {
+  
 	public class TabViewTests {
 		private TabView GetTabView ()
 		{

+ 1 - 0
UnitTests/TableViewTests.cs

@@ -8,6 +8,7 @@ using Xunit;
 using System.Globalization;
 
 namespace Terminal.Gui.Views {
+
 	public class TableViewTests 
 	{
 

+ 1 - 0
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 {