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.