GraphView.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. #nullable enable
  2. namespace Terminal.Gui;
  3. /// <summary>View for rendering graphs (bar, scatter, etc...).</summary>
  4. public class GraphView : View, IDesignable
  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. protected override bool OnDrawingContent (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 true;
  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 true;
  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. return true;
  221. }
  222. /// <summary>Scrolls the graph down 1 page.</summary>
  223. public void PageDown () { Scroll (0, -1 * CellSize.Y * Viewport.Height); }
  224. /// <summary>Scrolls the graph up 1 page.</summary>
  225. public void PageUp () { Scroll (0, CellSize.Y * Viewport.Height); }
  226. /// <summary>
  227. /// Clears all settings configured on the graph and resets all properties to default values (
  228. /// <see cref="CellSize"/>, <see cref="ScrollOffset"/> etc) .
  229. /// </summary>
  230. public void Reset ()
  231. {
  232. ScrollOffset = new PointF (0, 0);
  233. CellSize = new PointF (1, 1);
  234. AxisX.Reset ();
  235. AxisY.Reset ();
  236. Series.Clear ();
  237. Annotations.Clear ();
  238. GraphColor = null;
  239. SetNeedsDisplay ();
  240. }
  241. /// <summary>Returns the section of the graph that is represented by the given screen position.</summary>
  242. /// <param name="col"></param>
  243. /// <param name="row"></param>
  244. /// <returns></returns>
  245. public RectangleF ScreenToGraphSpace (int col, int row)
  246. {
  247. return new (
  248. ScrollOffset.X + (col - MarginLeft) * CellSize.X,
  249. ScrollOffset.Y + (Viewport.Height - (row + MarginBottom + 1)) * CellSize.Y,
  250. CellSize.X,
  251. CellSize.Y
  252. );
  253. }
  254. /// <summary>Returns the section of the graph that is represented by the screen area.</summary>
  255. /// <param name="screenArea"></param>
  256. /// <returns></returns>
  257. public RectangleF ScreenToGraphSpace (Rectangle screenArea)
  258. {
  259. // get position of the bottom left
  260. RectangleF pos = ScreenToGraphSpace (screenArea.Left, screenArea.Bottom - 1);
  261. return pos with { Width = screenArea.Width * CellSize.X, Height = screenArea.Height * CellSize.Y };
  262. }
  263. /// <summary>
  264. /// Scrolls the view by a given number of units in graph space. See <see cref="CellSize"/> to translate this into
  265. /// rows/cols.
  266. /// </summary>
  267. /// <param name="offsetX"></param>
  268. /// <param name="offsetY"></param>
  269. public void Scroll (float offsetX, float offsetY)
  270. {
  271. ScrollOffset = new (
  272. ScrollOffset.X + offsetX,
  273. ScrollOffset.Y + offsetY
  274. );
  275. SetNeedsDisplay ();
  276. }
  277. /// <summary>
  278. /// Sets the color attribute of <see cref="Application.Driver"/> to the <see cref="GraphColor"/> (if defined) or
  279. /// <see cref="ColorScheme"/> otherwise.
  280. /// </summary>
  281. public void SetDriverColorToGraphColor () { SetAttribute (GraphColor ?? GetNormalColor ()); }
  282. bool IDesignable.EnableForDesign ()
  283. {
  284. Title = "Sine Wave";
  285. var points = new ScatterSeries ();
  286. var line = new PathAnnotation ();
  287. // Draw line first so it does not draw over top of points or axis labels
  288. line.BeforeSeries = true;
  289. // Generate line graph with 2,000 points
  290. for (float x = -500; x < 500; x += 0.5f)
  291. {
  292. points.Points.Add (new (x, (float)Math.Sin (x)));
  293. line.Points.Add (new (x, (float)Math.Sin (x)));
  294. }
  295. Series.Add (points);
  296. Annotations.Add (line);
  297. // How much graph space each cell of the console depicts
  298. CellSize = new (0.1f, 0.1f);
  299. // leave space for axis labels
  300. MarginBottom = 2;
  301. MarginLeft = 3;
  302. // One axis tick/label per
  303. AxisX.Increment = 0.5f;
  304. AxisX.ShowLabelsEvery = 2;
  305. AxisX.Text = "X →";
  306. AxisX.LabelGetter = v => v.Value.ToString ("N2");
  307. AxisY.Increment = 0.2f;
  308. AxisY.ShowLabelsEvery = 2;
  309. AxisY.Text = "↑Y";
  310. AxisY.LabelGetter = v => v.Value.ToString ("N2");
  311. ScrollOffset = new (-2.5f, -1);
  312. return true;
  313. }
  314. }