2023-07-15 Drawing ASCII art charts in Emacs buffers

Two months ago I wrote about how I track my weight in Emacs. I am still doing it, and dieting works quite well, but I had one problem with my setup: I really wanted to have a graphical representation of my weight data – in other words, a chart. After quickly assessing the existing solutions (the chart.el library included in Emacs and Org Plot, I decided that I need something else. I don’t want to have to run an external program (in this case, gnuplot), and I want to have a simple ASCII art scatter plot (but neither a bar chart nor just an additional column containing a histogram of the values in the table.

For now, I’ll put aside the issue of getting the data from an Org table to a Lisp data structure – that will wait for another post.

This means that given a set of pairs of numbers, there are only two problems I need to solve: scaling them so that they’ll fit nicely on the screen and translating the coordinates to a buffer position. Neither is difficult, but I wanted to write as little code as possible. I knew about Picture mode and its cousin Artist mode, so I skimmed through their source code to look for something that could help me, and was not disappointed. There is the artist-move-to-xy function which moves the point to the place corresponding to given coordinates. There is also artist-replace-char, though it only works in Artist mode (since it requires artist-replacement-table to be correctly initialized).

This means that the only thing left is the scaling. I couldn’t find anything like it in Picture mode (or Artist mode), but it’s easy enough.

What I’d like to avoid is the rabbit hole of things like axis labels, ticks etc. I don’t need my chart to be exceptionally good-looking, so let’s keep those to a minimum. In fact, I don’t even need my code to be exceptionally good-looking, so I did not optimize it very thoroughly, neither for speed nor for elegance.

(require 'cl)
(defun scatter-plot--scale-value (value min max range)
  "Scale VALUE (between MIX and MAX) to [0,RANGE]."
  (round (* range (/ (- (* 1.0 value) min) (- max min)))))

(defun scatter-plot--scale-x (x minx maxx width margin-left)
  "Scale X between MINX and MAXX to [0,WIDTH] and add MARGIN-LEFT."
  (+ margin-left (scatter-plot--scale-value x minx maxx width)))

(defun scatter-plot--scale-y (y miny maxy height)
  "Scale Y between MINY and MAXY to [0,HEIGTH] and flip."
  (- height (scatter-plot--scale-value y miny maxy height)))

(defun scatter-plot (data params)
  "Return a string with a scatter plot of DATA.
DATA is a list of conses with coordinates.  PARAMS is a plist of
parameters."
  (let* ((minx (or (plist-get params :minx)
		   (apply #'min (mapcar #'car data))))
	 (maxx (or (plist-get params :maxx)
		   (apply #'max (mapcar #'car data))))
	 (miny (or (plist-get params :miny)
		   (apply #'min (mapcar #'cdr data))))
	 (maxy (or (plist-get params :maxy)
		   (apply #'max (mapcar #'cdr data))))
	 (width (or (plist-get params :width) 80))
	 (height (or (plist-get params :height) 50))
	 (char (or (plist-get params :char) ?*))
	 (margin-left (or (plist-get params :margin-left) 5))
	 (labelx-format (or (plist-get params :labelx-format) "%.1f"))
	 (labely-format (or (plist-get params :labely-format) "%.1f"))
	 (labelx-skip (or (plist-get params :labelx-skip) maxx))
	 (labely-skip (or (plist-get params :labely-skip) maxy))
	 (labelsp (or (plist-get params :labelsp) t))
	 (linesp (or (plist-get params :linesp) t)))
    (with-temp-buffer
      (artist-mode 1)
      (when linesp
	(artist-draw-sline (scatter-plot--scale-x minx minx maxx width margin-left)
			   (scatter-plot--scale-y miny miny maxy height)
			   (scatter-plot--scale-x maxx minx maxx width margin-left)
			   (scatter-plot--scale-y miny miny maxy height))
	(artist-draw-sline (scatter-plot--scale-x minx minx maxx width margin-left)
			   (scatter-plot--scale-y maxy miny maxy height)
			   (scatter-plot--scale-x minx minx maxx width margin-left)
			   (scatter-plot--scale-y miny miny maxy height)))
      (when labelsp
	(cl-loop for x from minx to maxx by labelx-skip
		 do (artist-text-insert-overwrite
		     (scatter-plot--scale-x x minx maxx width margin-left)
		     (+ 2 (scatter-plot--scale-y miny miny maxy height)) ; shift X labels down
		     (format labelx-format x)))
	(cl-loop for y from miny to maxy by labely-skip
		 do (artist-text-insert-overwrite
		     (scatter-plot--scale-x minx minx maxx width 0) ; do not shift Y labels right
		     (scatter-plot--scale-y y miny maxy height)
		     (format labely-format y))))
      (mapc (lambda (point)
	      (let* ((x (scatter-plot--scale-x (car point) minx maxx width margin-left))
		     (y (scatter-plot--scale-y (cdr point) miny maxy height)))
		(artist-move-to-xy x y))
	      (artist-replace-char char)
	      (put-text-property (1- (point))
				 (point)
				 'help-echo
				 (format "(%s,%s)" (car point) (cdr point))))
	    data)
      (buffer-string))))

And now I can say something like

(insert
 (scatter-plot
  (cl-loop for x from 0 to (* 2 float-pi) by (/ float-pi 18) collect (cons x (sin x)))
  (list :width 36
        :height 12
        :minx 0
        :maxx (* 2 float-pi)
        :miny -1
        :maxy 1
        :labelx-skip (/ float-pi 2)
        :labely-skip 1
        :labelx-format "%.2f")))

and get this.

1.0  |      *****
     |    **     **
     |   *         *
     |  *           *
     | *             *
     |*               *
0.0  *                 *                 *
     |                  *               *
     |                   *             *
     |                    *           *
     |                     *         *
     |                      **     **
-1.0 +------------------------*****-------

     0.00     1.57     3.14     4.71     6.28

As you can see, it’s not ideal – but since I do not need to draw precise graphs of functions with this, just chart some tabular data, it should be perfectly fine for my use-case.

CategoryEnglish, CategoryBlog, CategoryEmacs