DEV Community

J David Eisenberg
J David Eisenberg

Posted on

Drawing on a Canvas in ReScript (Part 3)

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

And then change the <script> element in index.html:

 <script type="text/javascript" src="Plot.bs.js"></script>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)))
}
Enter fullscreen mode Exit fullscreen mode

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 => ()
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

The call to drawLines() comes at the very end of the plot() function:

drawLines((plotAs == Polar) ? getPolar : getLissajous)
Enter fullscreen mode Exit fullscreen mode

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/

Oldest comments (0)