GraphView.cs 11 KB

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