Give Me a Sine
November 05, 2019 #Clojure #ClojureScript #JavaScript #MathOne quiet night I was remembering how much fun I had plotting graphics on my TRS-80 Color Computer and lamenting the lack of any decent GUI platforms on modern computers. Specifically, there was one trig function plotter from my CoCo's manual that I thought would be fun to port.
Polar Dares
The book and code are long gone, but the output looked a little like Fig. 1. But how should we reproduce it now?
I still have echoes of PTSD from wrangling cross-platform JavaScript and CSS back when it was still called 'DHTML,' but I thought the Canvas HTML element's 2D API would be a good, erm, canvas for my polar masterpiece! The 2D API provides exactly the Cartesian plotting interface we need.
First we pick an overall size
for the square canvas. Halve it on each axis to give us an origin
.
Then we fetch the canvas
element itself and create a 2D context
for it, setting its dimensions
while we're at it.
Inside the loop, we plot each value of t
(θ) over the range [0, 2π) by fractional
increments. We compute r
using the trig function cos. We then translate the polar
coordinates (r, t) to a Cartesian point (x, y) and plot it relative to our
origin
.
The canvas routines like fillRect
will automatically perform subpixel antialiasing ("dithering")
for fractional coordinates, which can consume unnecessary CPU. To minimize this, we use a JavaScript
bitwise trick to round to the nearest integer. This isn't crucial in this routine, but may be
helpful soon.
const size = 150, origin = size / 2;
const canvas = document.getElementById("graph-js1");
canvas.height = canvas.width = size;
const context = canvas.getContext('2d');
for (let t = 0; t < 2 * Math.PI; t += 0.007) {
const r = 0.9 * origin * Math.cos(2 * t);
const x = origin + r * Math.cos(t);
const y = origin - r * Math.sin(t);
context.fillRect(~~(0.5 + x), ~~(0.5 + y), 1, 1);
}
So here is a minimal implementation. Feel free to play around with the code. In the next section, we'll look at how to make it feel more authentically TRS-80.
Recursive Nostalgia
BASIC's FOR
loop translates nicely to JavaScript's for
loop. But graphics plotting on the CoCo
was not only imperative and synchronous, but a lot slower! How could I reproduce the satisfyingly
tortoise-like plot speed of the Motorola 6809E (approximated in Fig. 2) with
modern JavaScript?
In most other languages this would be a simple sleep()
or wait()
call, but JavaScript is not
only inherently asynchronous, but single-threaded. JavaScript won't let us tie it up in a sleep call
because that one thread has to keep the entire browser page running!
In JavaScript, inserting delays in code is handled using time-outs and callbacks. But to use that we have to rethink how our program is structured. Larger tasks need to be broken down into discrete steps. Each invocation of our function performs one step. Then before we exit the function, we set a "timeout" — a delay after which we perform the next step. And in that intervening time, the browser can handle other things, like network I/O, or user interaction.
So we need to break our larger task ("Plot values from 0 to 2π") into discrete steps ("Plot one
point at regular intervals"). The body of the function is essentially the same as the body of the
for
loop above:
- calculate the (x, y) coordinates from
t
- plot the new coordinates
- set a timeout to plot the next
t
Steps (1) and (2) — the body of our for
loop above — will form the body of the new
graphLoop
callback function, along with a setTimeout
delay just long enough to create that
authentic sub-MHz CPU feel.
The next invocation of the loop won't know what value of t
to use, so we'll have to pass that
along with. And since the setTimeout
function doesn't send parameters, we have to create a 0-arity
lambda that does this.
There are also a few quirks of the Klipse widget that I had to work around. Every code change
causes the whole block to be re-evaluated, so I took advantage of JavaScript's atrocious variable
scoping to allow the outdated graphLoop
calls to exit cleanly. Here are the highlights:
f
is defined as avar
to make it visible across re-evaluations of the code.f
is an arrow function, which creates a unique object every evaluation.- On entry to
graphLoop
, we check iff
has been recreated the code. If so, return without drawing or setting a new time-out. - When the full graph period (
[0, 2π)
) is complete, clear the graph and start over at 0.
Here is the final code currently driving Fig. 2.
var f = (t) => Math.cos(3 * t);
var paused = false;
const delay = 30;
const size = 300;
const origin = size / 2;
const canvas = document.getElementById("graph-js2");
canvas.height = canvas.width = size;
canvas.onclick = () => (paused = !paused);
const context = canvas.getContext('2d');
context.clearRect(0, 0, size, size);
function graphLoop(_f, t) {
if (_f !== f) return; // exit loop if code modified
if (!paused) {
const r = 0.9 * origin * _f(t);
const x = origin + r * Math.cos(t);
const y = origin + r * Math.sin(t);
context.fillRect(~~(0.5 + x), ~~(0.5 + y), 1, 1);
if (t >= Math.PI) {
context.clearRect(0, 0, size, size);
t = 0;
} else {
t += 0.006;
}
setTimeout(() => graphLoop(_f, t), delay);
} else {
setTimeout(() => graphLoop(_f, t), 200);
}
}
graphLoop(f, 0);
The Challenge
We spent the previous section refactoring our for
loop so we could slow the plotting down. In
this exercise, we're not going to plot trig functions one point at a time. We're going to plot about
a hundred functions per second, end-to-end. Let's play with Canvas for a while.
We spent the previous section refactoring our JavaScript for
loop so we could slow the plotting
down. But in this exercise, we're going to plot about a hundred functions per second,
end-to-end. Let's see how much more we can do with Canvas, and play with ClojureScript at
the same time.
ClojureScript is a variant of the Clojure language that runs on top of a JavaScript runtime, usually either Node.js or in a browser. If you're not familiar with Clojure code, it looks and works similarly to Scheme or Racket. And if you've never worked with any Lisp before, no worries; you should recognize enough of the operations and idioms from above to get the gist.
(defonce eval-id (atom 0)) ; persistent counter
(defonce paused (atom true)) ; persistent toggle
(defonce round #(bit-not (bit-not (+ 0.5 %))))
(defonce size 300)
(defonce origin (/ size 2))
(defonce context
(let [canvas (.getElementById js/document "graph-js3")]
(set! (.-height canvas) size)
(set! (.-width canvas) size)
(set! (.-onclick canvas) #(swap! paused not))
(.getContext canvas "2d")))
(defn f [x] (js/Math.sin (* 2 x)))
(defn graph-loop [n eid]
(when (= eid @eval-id)
;; if paused, check back after a slightly longer delay
(if @paused (js/setTimeout #(graph-loop n eid) 200)
(do ; otherwise, plot the next sequence
(.clearRect context 0 0 size size) ; clear graph
(loop [t 0] ; plot f from 0...
(let [r (* 0.9 origin (f (* n t)))
x (+ origin (* r (js/Math.cos t)))
y (- origin (* r (js/Math.sin t)))]
(.fillRect context (round x) (round y) 1 1))
(when (< t (* 2 js/Math.PI)) ; ...to 2π
(recur (+ t 0.004))))
(js/setTimeout #(graph-loop (+ n 0.002) eid) 16)))))
(graph-loop 0 (swap! eval-id inc)) ; bump eval-id for every evaluation
(str "Click box to " (if @paused "start" "stop") " plot #" @eval-id)
An introduction to Clojure is outside the scope of this article, but let's go over a few functional differences from the previous code samples.
The defonce
macro ensures that this expression is evaluated and
bound exactly once. E.g., there should be only one canvas
element and
only one atom determining the paused state. This means we don't have to
use any JavaScript scoping hacks to approximate these "static" values.
Atoms are one of the few mutable data types that Clojure
provides. An atom is a little pocket of mutable state. The contents of
that pocket can be read at any time using @
to "dereference" it, but
can only be modified using the special functions swap!
and
reset!
.
At the start of the loop, (= eid @eval-id)
checks the value inside the
the eval-id
atom against the eid
used to start the loop. When
(and only when
) they're equal, the loop continues. This prevents multiple instances of
graph-loop
from competing for the canvas.
Finally we do our per-eval setup, setting the size of the canvas to the
current value of size
, then kicking off the graph-loop
with a fresh
eval-id
.
Special Thanks
This post was both inspired and made possible by Klipse, a website plugin that enables developers to embed interactive code snippets just like the ones above. It supports far languages than JavaScript and ClojureScript, all easily embedded in your existing pages. Try it out live in the tutorial, or add it to your existing website for an entirely new dimension of instructive coding!