Drawing on a Canvas in ReScript (Part 3)
J David Eisenberg
Posted on August 28, 2020
In the preceding two articles, we set up bs-webapi
and extracted the data from the fields in the HTML page. In this article, we’ll complete the task and use the information to draw a polar or Lissajous figure graph.
Moving Things Around
The DomGraphs.res
file is getting a bit large. Rather than put everything into one file, create a new Plot.res
file in the src
directory.
Start it with some module aliases:
module DOM = Webapi.Dom
module Doc = Webapi.Dom.Document
module Elem = Webapi.Dom.Element
module Node = Webapi.Dom.Node
module Evt = Webapi.Dom.Event
module EvtTarget = Webapi.Dom.EventTarget
module Canvas = Webapi.Canvas
module CanvasElement = Webapi.Canvas.CanvasElement
module C2d = Webapi.Canvas.Canvas2d
module Result = Belt.Result
Move the draw
function and the initialization from DomGraphs.res
into Plot.res
. This will require adding the DomGraphs
module name in order to reference its functions.
let draw = (_evt) => {
let formula1 = DomGraphs.getFormula("1")
let formula2 = DomGraphs.getFormula("2")
let plotAs = DomGraphs.getRadioValue([("polar", DomGraphs.Polar),
("lissajous", DomGraphs.Lissajous)], DomGraphs.Polar)
switch (formula1, formula2) {
| (Belt.Result.Ok(f1), Belt.Result.Ok(f2)) => {
plot(f1, f2, plotAs)
}
| (Belt.Result.Error(e1), _) => DOM.Window.alert(e1, DOM.window)
| (_, Belt.Result.Error(e2)) => DOM.Window.alert(e2, DOM.window)
}
}
let optButton = Doc.getElementById("draw", DOM.document)
switch (optButton) {
| Some(button) => {
EvtTarget.addClickEventListener(draw, Elem.asEventTarget(button))
}
| None => DOM.Window.alert("Cannot find button", DOM.window)
}
And then change the <script>
element in index.html
:
<script type="text/javascript" src="Plot.bs.js"></script>
As a convenience for keeping function annotations readable, define these types:
type polar = (float, float) // (radius, angle in degrees)
type cartesian = (float, float) // (0.0, 0.0) is at center
type canvasCoord = (float, float) // (0.0, 0.0) is at top left
Since the user interface is in degrees, I decided to keep polar coordinates in degrees and convert to radians only when necessary. Here are utility routines for that purpose:
let radians = (degrees: float): float => {
degrees *. Js.Math._PI /. 180.0
}
let toCartesian = ((r, theta): polar): cartesian => {
(r *. cos(radians(theta)), r *. sin(radians(theta)))
}
If you compile the code now, it says that the plot()
function hasn’t been written yet. Let’s do that. Start by getting the <canvas>
, its drawing context, and its dimensions. Then erase the canvas.
let plot = (f1: DomGraphs.formula, f2: DomGraphs.formula,
plotAs: DomGraphs.graphType): unit => {
switch (Doc.getElementById("canvas", DOM.document)) {
| Some(element) => {
let context = Canvas.CanvasElement.getContext2d(element);
let width = float_of_int(Canvas.CanvasElement.width(element));
let height = float_of_int(Canvas.CanvasElement.height(element));
let centerX = width /. 2.0;
let centerY = height /. 2.0;
C2d.setFillStyle(context, String, "white");
C2d.fillRect(~x=0.0, ~y=0.0, ~w=width, ~h=height, context);
| None => ()
}
}
Next, calculate the amplitude of the wave form; this is the maximum of 1.0 and the sum of the factors in the formulas. This enables us to write a function to convert a coordinate point where (0, 0) is at the center of a graph to the canvas coordinates where (0, 0) is at the upper left of the coordinate system.
let amplitude = Js.Math.max_float(1.0, abs_float(f1.factor) +. abs_float(f2.factor))
let toCanvas = ((x, y): cartesian): canvasCoord => {
((centerX /. amplitude) *. x +. centerX,
(-.centerY /. amplitude) *.y +. centerY)
}
The process of drawing the graph will require evaluating a formula
at a given number of degrees:
let evaluate = (f: DomGraphs.formula, angle: float): float => {
f.factor *. f.fcn(f.theta *. (radians(angle)) +. radians(f.offset))
}
The code for plotting a graph starts at an angle of 0° and evaluates the formulas, combining their results into a point for a polar or Lissajous figure (depending on the user’s choice). The code then repeatedly adds 3° and re-evaluates to find the next point on the curve. The question then becomes: how many times should we iterate in order to ensure a closed figure? For example, if one formula is 3·sin(θ) and the other is 5·cos(θ), then going through 15 times 360° should put the curves “back in sync” with one another. In general, a naïve approach says that the number of degrees we need is 360 times the least common multiple of the theta-factors.
What if you have factors like 3.75 and 7.2? The solution we’ll use is to multiply them factors by 100, find the least common multiple, and divide that result by 100. Here’s the relevant code, with the keyword rec
to indicate that the gcd()
function is recursive.
let rec gcd = (m: int, n:int): int => {
if (m == n) {
m
} else if (m > n) {
gcd(m - n, n)
} else {
gcd(m, n - m)
}
}
let lcm = (m: int, n: int): int => {
(m * n) / gcd(m, n)
}
let lcm_float = (m:float, n:float): float => {
float_of_int(lcm(int_of_float(m *. 100.0),
int_of_float(n *. 100.0))) /. 100.0
}
The code for drawing the lines for a polar or Lissajous figure is identical except for the function that determines the (x, y) point to plot, which becomes the parameter to drawLines()
.
let drawLines = (getXY: (float)=>cartesian): unit => {
let increment = 3.0
let limit = 360.0 *. lcm_float(formula1.theta, formula2.theta)
let rec helper = (d: float) => {
if (d >= limit) {
()
} else {
let (x, y) = toCanvas(getXY(d))
C2d.lineTo(~x = x, ~y = y, context)
helper(d +. increment)
}
}
let (x, y) = toCanvas(getXY(0.0))
C2d.setStrokeStyle(context, String, "#000")
C2d.beginPath(context)
C2d.moveTo(context, ~x=x, ~y=y)
helper(increment)
C2d.closePath(context)
C2d.stroke(context)
// draw the plot lines
}
The helper()
function is tail-recursive; the recursive call is the last operation when recursion occurs. ReScript will optimize this into a while
loop in JavaScript, so there is no danger of a stack overflow from too many recursive calls.
We then need to provide the functions for getting the polar coordinates for polar and Lissajous plots:
let getPolar = (theta): cartesian => {
let r1 = evaluate(formula1, theta)
let r2 = evaluate(formula2, theta)
toCartesian((r1 +. r2, theta))
}
let getLissajous = (theta): cartesian => {
let r1 = evaluate(formula1, theta)
let r2 = evaluate(formula2, theta)
(r1, r2)
}
The call to drawLines()
comes at the very end of the plot()
function:
drawLines((plotAs == Polar) ? getPolar : getLissajous)
Summary
In this series of articles, you have seen how to set up a ReScript project to use the bs-webapi
library, use bs-webapi
to extract information from input fields, and how to use that information to draw on a <canvas>
element.
The source code for this project is at https://github.com/jdeisenberg/domgraphs; there are three branches: master
(part 1), getFieldData
(part 2), and plotGraphs
(part 3).
You can see the code in action at http://langintro.com/rescript/domgraphs/
Posted on August 28, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.