Drawing on a Canvas in ReScript (Part 3)
Tags: rescript
Published: 2020-08-28 03:27:58

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:

```reasonml
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.

```reasonml
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`:

```html
 <script type="text/javascript" src="Plot.bs.js"></script>
```

As a convenience for keeping function annotations readable, define these types:

```reasonml
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:

```reasonml
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.

```reasonml
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.

```reasonml
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:

```reasonml
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&deg; 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&deg; 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&middot;sin(&theta;) and the other is 5&middot;cos(&theta;), then going through 15 times 360&deg; 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.

```reasonml
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()`.

```reasonml
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:

```reasonml
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:

```reasonml
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/