GraphView.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. #nullable enable
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Linq;
  5. using System.Text;
  6. namespace Terminal.Gui;
  7. /// <summary>
  8. /// View for rendering graphs (bar, scatter, etc...).
  9. /// </summary>
  10. public class GraphView : View {
  11. /// <summary>
  12. /// Creates a new graph with a 1 to 1 graph space with absolute layout.
  13. /// </summary>
  14. public GraphView ()
  15. {
  16. CanFocus = true;
  17. AxisX = new HorizontalAxis ();
  18. AxisY = new VerticalAxis ();
  19. // Things this view knows how to do
  20. AddCommand (Command.ScrollUp, () => {
  21. Scroll (0, CellSize.Y);
  22. return true;
  23. });
  24. AddCommand (Command.ScrollDown, () => {
  25. Scroll (0, -CellSize.Y);
  26. return true;
  27. });
  28. AddCommand (Command.ScrollRight, () => {
  29. Scroll (CellSize.X, 0);
  30. return true;
  31. });
  32. AddCommand (Command.ScrollLeft, () => {
  33. Scroll (-CellSize.X, 0);
  34. return true;
  35. });
  36. AddCommand (Command.PageUp, () => {
  37. PageUp ();
  38. return true;
  39. });
  40. AddCommand (Command.PageDown, () => {
  41. PageDown ();
  42. return true;
  43. });
  44. KeyBindings.Add (KeyCode.CursorRight, Command.ScrollRight);
  45. KeyBindings.Add (KeyCode.CursorLeft, Command.ScrollLeft);
  46. KeyBindings.Add (KeyCode.CursorUp, Command.ScrollUp);
  47. KeyBindings.Add (KeyCode.CursorDown, Command.ScrollDown);
  48. // Not bound by default (preserves backwards compatibility)
  49. //KeyBindings.Add (Key.PageUp, Command.PageUp);
  50. //KeyBindings.Add (Key.PageDown, Command.PageDown);
  51. }
  52. /// <summary>
  53. /// Horizontal axis.
  54. /// </summary>
  55. /// <value></value>
  56. public HorizontalAxis AxisX { get; set; }
  57. /// <summary>
  58. /// Vertical axis.
  59. /// </summary>
  60. /// <value></value>
  61. public VerticalAxis AxisY { get; set; }
  62. /// <summary>
  63. /// Collection of data series that are rendered in the graph.
  64. /// </summary>
  65. public List<ISeries> Series { get; } = new ();
  66. /// <summary>
  67. /// Elements drawn into graph after series have been drawn e.g. Legends etc.
  68. /// </summary>
  69. public List<IAnnotation> Annotations { get; } = new ();
  70. /// <summary>
  71. /// Amount of space to leave on left of the graph. Graph content (<see cref="Series"/>)
  72. /// will not be rendered in margins but axis labels may be. Use <see cref="Padding"/> to
  73. /// add a margin outside of the GraphView.
  74. /// </summary>
  75. public uint MarginLeft { get; set; }
  76. /// <summary>
  77. /// Amount of space to leave on bottom of the graph. Graph content (<see cref="Series"/>)
  78. /// will not be rendered in margins but axis labels may be. Use <see cref="Padding"/> to
  79. /// add a margin outside of the GraphView.
  80. /// </summary>
  81. public uint MarginBottom { get; set; }
  82. /// <summary>
  83. /// The graph space position of the bottom left of the graph.
  84. /// Changing this scrolls the viewport around in the graph.
  85. /// </summary>
  86. /// <value></value>
  87. public PointF ScrollOffset { get; set; } = new (0, 0);
  88. /// <summary>
  89. /// Translates console width/height into graph space. Defaults
  90. /// to 1 row/col of console space being 1 unit of graph space.
  91. /// </summary>
  92. /// <returns></returns>
  93. public PointF CellSize { get; set; } = new (1, 1);
  94. /// <summary>
  95. /// The color of the background of the graph and axis/labels.
  96. /// </summary>
  97. public Attribute? GraphColor { get; set; }
  98. /// <summary>
  99. /// Clears all settings configured on the graph and resets all properties
  100. /// to default values (<see cref="CellSize"/>, <see cref="ScrollOffset"/> etc) .
  101. /// </summary>
  102. public void Reset ()
  103. {
  104. ScrollOffset = new PointF (0, 0);
  105. CellSize = new PointF (1, 1);
  106. AxisX.Reset ();
  107. AxisY.Reset ();
  108. Series.Clear ();
  109. Annotations.Clear ();
  110. GraphColor = null;
  111. SetNeedsDisplay ();
  112. }
  113. ///<inheritdoc/>
  114. public override void OnDrawContent (Rect contentArea)
  115. {
  116. if (CellSize.X == 0 || CellSize.Y == 0) {
  117. throw new Exception ($"{nameof (CellSize)} cannot be 0");
  118. }
  119. SetDriverColorToGraphColor ();
  120. Move (0, 0);
  121. // clear all old content
  122. for (var i = 0; i < Bounds.Height; i++) {
  123. Move (0, i);
  124. Driver.AddStr (new string (' ', Bounds.Width));
  125. }
  126. // If there is no data do not display a graph
  127. if (!Series.Any () && !Annotations.Any ()) {
  128. return;
  129. }
  130. // The drawable area of the graph (anything that isn't in the margins)
  131. var graphScreenWidth = Bounds.Width - (int)MarginLeft;
  132. var graphScreenHeight = Bounds.Height - (int)MarginBottom;
  133. // if the margins take up the full draw bounds don't render
  134. if (graphScreenWidth < 0 || graphScreenHeight < 0) {
  135. return;
  136. }
  137. // Draw 'before' annotations
  138. foreach (var a in Annotations.ToArray ().Where (a => a.BeforeSeries)) {
  139. a.Render (this);
  140. }
  141. SetDriverColorToGraphColor ();
  142. AxisY.DrawAxisLine (this);
  143. AxisX.DrawAxisLine (this);
  144. AxisY.DrawAxisLabels (this);
  145. AxisX.DrawAxisLabels (this);
  146. // Draw a cross where the two axis cross
  147. var axisIntersection = new Point (AxisY.GetAxisXPosition (this), AxisX.GetAxisYPosition (this));
  148. if (AxisX.Visible && AxisY.Visible) {
  149. Move (axisIntersection.X, axisIntersection.Y);
  150. AddRune (axisIntersection.X, axisIntersection.Y, (Rune)'\u253C');
  151. }
  152. SetDriverColorToGraphColor ();
  153. var drawBounds = new Rect ((int)MarginLeft, 0, graphScreenWidth, graphScreenHeight);
  154. var graphSpace = ScreenToGraphSpace (drawBounds);
  155. foreach (var s in Series.ToArray ()) {
  156. s.DrawSeries (this, drawBounds, graphSpace);
  157. // If a series changes the graph color reset it
  158. SetDriverColorToGraphColor ();
  159. }
  160. SetDriverColorToGraphColor ();
  161. // Draw 'after' annotations
  162. foreach (var a in Annotations.ToArray ().Where (a => !a.BeforeSeries)) {
  163. a.Render (this);
  164. }
  165. }
  166. /// <summary>
  167. /// Sets the color attribute of <see cref="Application.Driver"/> to the <see cref="GraphColor"/>
  168. /// (if defined) or <see cref="ColorScheme"/> otherwise.
  169. /// </summary>
  170. public void SetDriverColorToGraphColor () => Driver.SetAttribute (GraphColor ?? GetNormalColor ());
  171. /// <summary>
  172. /// Returns the section of the graph that is represented by the given
  173. /// screen position.
  174. /// </summary>
  175. /// <param name="col"></param>
  176. /// <param name="row"></param>
  177. /// <returns></returns>
  178. public RectangleF ScreenToGraphSpace (int col, int row) => new (
  179. ScrollOffset.X + (col - MarginLeft) * CellSize.X,
  180. ScrollOffset.Y + (Bounds.Height - (row + MarginBottom + 1)) * CellSize.Y,
  181. CellSize.X, CellSize.Y);
  182. /// <summary>
  183. /// Returns the section of the graph that is represented by the screen area.
  184. /// </summary>
  185. /// <param name="screenArea"></param>
  186. /// <returns></returns>
  187. public RectangleF ScreenToGraphSpace (Rect screenArea)
  188. {
  189. // get position of the bottom left
  190. var pos = ScreenToGraphSpace (screenArea.Left, screenArea.Bottom - 1);
  191. return new RectangleF (pos.X, pos.Y, screenArea.Width * CellSize.X, screenArea.Height * CellSize.Y);
  192. }
  193. /// <summary>
  194. /// Calculates the screen location for a given point in graph space.
  195. /// Bear in mind these may be off screen.
  196. /// </summary>
  197. /// <param name="location">
  198. /// Point in graph space that may or may not be represented in the
  199. /// visible area of graph currently presented. E.g. 0,0 for origin.
  200. /// </param>
  201. /// <returns>
  202. /// Screen position (Column/Row) which would be used to render the graph <paramref name="location"/>.
  203. /// Note that this can be outside the current content area of the view.
  204. /// </returns>
  205. public Point GraphSpaceToScreen (PointF location) => new (
  206. (int)((location.X - ScrollOffset.X) / CellSize.X) + (int)MarginLeft,
  207. // screen coordinates are top down while graph coordinates are bottom up
  208. Bounds.Height - 1 - (int)MarginBottom - (int)((location.Y - ScrollOffset.Y) / CellSize.Y)
  209. );
  210. /// <inheritdoc/>
  211. /// <remarks>Also ensures that cursor is invisible after entering the <see cref="GraphView"/>.</remarks>
  212. public override bool OnEnter (View view)
  213. {
  214. Driver.SetCursorVisibility (CursorVisibility.Invisible);
  215. return base.OnEnter (view);
  216. }
  217. /// <summary>
  218. /// Scrolls the graph up 1 page.
  219. /// </summary>
  220. public void PageUp () => Scroll (0, CellSize.Y * Bounds.Height);
  221. /// <summary>
  222. /// Scrolls the graph down 1 page.
  223. /// </summary>
  224. public void PageDown () => Scroll (0, -1 * CellSize.Y * Bounds.Height);
  225. /// <summary>
  226. /// Scrolls the view by a given number of units in graph space.
  227. /// See <see cref="CellSize"/> to translate this into rows/cols.
  228. /// </summary>
  229. /// <param name="offsetX"></param>
  230. /// <param name="offsetY"></param>
  231. public void Scroll (float offsetX, float offsetY)
  232. {
  233. ScrollOffset = new PointF (
  234. ScrollOffset.X + offsetX,
  235. ScrollOffset.Y + offsetY);
  236. SetNeedsDisplay ();
  237. }
  238. #region Bresenham's line algorithm
  239. // https://rosettacode.org/wiki/Bitmap/Bresenham%27s_line_algorithm#C.23
  240. /// <summary>
  241. /// Draws a line between two points in screen space. Can be diagonals.
  242. /// </summary>
  243. /// <param name="start"></param>
  244. /// <param name="end"></param>
  245. /// <param name="symbol">The symbol to use for the line</param>
  246. public void DrawLine (Point start, Point end, Rune symbol)
  247. {
  248. if (Equals (start, end)) {
  249. return;
  250. }
  251. var x0 = start.X;
  252. var y0 = start.Y;
  253. var x1 = end.X;
  254. var y1 = end.Y;
  255. int dx = Math.Abs (x1 - x0), sx = x0 < x1 ? 1 : -1;
  256. int dy = Math.Abs (y1 - y0), sy = y0 < y1 ? 1 : -1;
  257. int err = (dx > dy ? dx : -dy) / 2, e2;
  258. while (true) {
  259. AddRune (x0, y0, symbol);
  260. if (x0 == x1 && y0 == y1) {
  261. break;
  262. }
  263. e2 = err;
  264. if (e2 > -dx) {
  265. err -= dy;
  266. x0 += sx;
  267. }
  268. if (e2 < dy) {
  269. err += dx;
  270. y0 += sy;
  271. }
  272. }
  273. }
  274. #endregion
  275. }