GraphView.cs 12 KB

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