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.