DEV Community

Maxwell Brown
Maxwell Brown

Posted on

graphics-ts: Functional Bindings for the HTML 5 Canvas API

In this post, I will describe the functional design of graphics-ts, a part of the fp-ts ecosystem that provides functional bindings for the HTML 5 Canvas API.

For those unfamiliar with fp-ts, I encourage you to read @gcanti's excellent series on Getting starting with fp-ts.

The HTML 5 Canvas API

As described by the Mozilla Developer Network Web Docs:

The Canvas API provides a means for drawing graphics via JavaScript and the HTML <canvas> element. Among other things, it can be used for animation, game graphics, data visualization, photo manipulation, and real-time video processing.

Here is a basic example of using the Canvas API to draw a house:

Html
<canvas id="my-house" width="300" height="300"></canvas>
Enter fullscreen mode Exit fullscreen mode
JavaScript
const draw = () => {
  const canvas = document.getElementById('my-house')

  if (canvas) {
    const context = canvas.getContext('2d')
    // Set line width
    context.lineWidth = 10;
    // Wall
    context.strokeRect(75, 140, 150, 110);
    // Door
    context.fillRect(130, 190, 40, 60);
    // Roof
    context.beginPath();
    context.moveTo(50, 140);
    context.lineTo(150, 60);
    context.lineTo(250, 140);
    context.closePath();
    context.stroke();
  }
}
Enter fullscreen mode Exit fullscreen mode
Output

House

As you can see, rendering to a <canvas> element is imperative by nature and involves repeated mutation of the canvas context.

Functional Programming and the Canvas API

The Canvas Module

In graphics-ts, operations made against the canvas are modeled by the IO type class.

From Getting started with fp-ts:

In fp-ts a synchronous effectful computation is represented by the IO type, which is basically a thunk, i.e. a function with the following signature: () => A.

By representing canvas operations as instances of IO, we are pushing evaluation of the canvas to the boundary of our program's execution. This is because an IO is just a value which represents an effectful computation, so in order to execute any side effect you must execute the IO action.

Getting Access to the Canvas

Before we can start drawing to a <canvas> element, we need to get a reference to it, as well as to its context.

export const unsafeGetCanvasElementById: (id: string) => HTMLCanvasElement = (id) =>
  document.getElementById(id) as HTMLCanvasElement

export const unsafeGetContext2D: (canvas: HTMLCanvasElement) => CanvasRenderingContext2D = (c) =>
  c.getContext('2d') as CanvasRenderingContext2D
Enter fullscreen mode Exit fullscreen mode

But there is a problem here - these operations are not running in the IO context. To solve this, we can lift these functions into the IO context.

import * as IO from 'fp-ts/lib/IO'

export const getCanvasElementById: (id: string) => IO.IO<O.Option<HTMLCanvasElement>> = (id) => () => {
  const canvas = unsafeGetCanvasElementById(id)
  return canvas instanceof HTMLCanvasElement ? O.some(canvas) : O.none
}

export const getContext2D: (canvas: HTMLCanvasElement) => IO.IO<CanvasRenderingContext2D> = (c) =>
  IO.of(unsafeGetContext2D(c))
Enter fullscreen mode Exit fullscreen mode

Abstracting Canvas Operations

Now we can start working on implementing the rest of our API.

Example (canvas dimensions)

import * as IO from 'fp-ts/lib/IO'

export const getWidth: (canvas: HTMLCanvasElement) => IO.IO<number> = (c) => () => c.width

export const setWidth: (width: number) => (canvas: HTMLCanvasElement) => IO.IO<void> = (w) => (c) => () => {
  c.width = w
}

export const getHeight: (canvas: HTMLCanvasElement) => IO.IO<number> = (c) => () => c.height

export const setHeight: (height: number) => (canvas: HTMLCanvasElement) => IO.IO<void> = (h) => (c) => {
c.height = h


export interface CanvasDimensions {
  readonly width: number
  readonly height: number
}

export const getDimensions: (canvas: HTMLCanvasElement) => IO.IO<CanvasDimensions> = (c) =>
  sequenceS(IO.io)({ height: getHeight(c), width: getWidth(c) })

export const setDimensions: (dimensions: CanvasDimensions) => (canvas: HTMLCanvasElement) => IO.IO<void> = (d) => (c) =>
  pipe(
    c,
    setWidth(d.width),
    IO.chain(() => pipe(c, setHeight(d.height)))
  )
Enter fullscreen mode Exit fullscreen mode

Example (stroke a path)

export const strokePath: <A>(
  f: (ctx: CanvasRenderingContext2D) => IO.IO<A>
) => (ctx: CanvasRenderingContext2D) => IO.IO<A> = (f) => (ctx) =>
  pipe(
    ctx,
    beginPath,
    IO.chain(() => f(ctx)),
    IO.chain((a) =>
      pipe(
        ctx,
        stroke(),
        IO.map(() => a)
      )
    )
  )
Enter fullscreen mode Exit fullscreen mode

Refactoring our Domain Model

If we examine our API as we continue, we will notice that almost all functions have the following signatures:

HTMLCanvasElement

(canvas: HTMLCanvasElement) => IO.IO<A>
Enter fullscreen mode Exit fullscreen mode

CanvasRenderingContext2D

(ctx: CanvasRenderingContext2D) => IO.IO<CanvasRenderingContext2D>
Enter fullscreen mode Exit fullscreen mode

Essentially, we are reading from an HTMLCanvasElement or CanvasRenderingContext2D and returning a type A wrapped in an IO.

So we could say that when managing HTMLCanvasElement we are yielding an Html effect, when managing CanvasRenderingContext2D we are yielding a Render effect, and when managing CanvasGradient we are yielding a Gradient effect.

We can model these effects using the Reader module from fp-ts.

import * as R from 'fp-ts/lib/Reader'

export interface Html<A> extends R.Reader<HTMLCanvasElement, IO.IO<A>> {}

export interface Render<A> extends R.Reader<CanvasRenderingContext2D, IO.IO<A>> {}

export interface Gradient<A> extends R.Reader<CanvasGradient, IO.IO<A>> {}
Enter fullscreen mode Exit fullscreen mode

So our examples from above become the following:

Example (canvas dimensions)

export const getWidth: Html<number> = (c) => () => c.width

export const setWidth: (width: number) => Html<HTMLCanvasElement> = (w) => (c) => () => {
  c.width = w
  return c
}

export const getHeight: Html<number> = (c) => () => c.height

export const setHeight: (height: number) => Html<HTMLCanvasElement> = (h) => (c) => () => {
  c.height = h
  return c
}

export const getDimensions: Html<CanvasDimensions> = (c) =>
  sequenceS(IO.io)({ height: getHeight(c), width: getWidth(c) })

export const setDimensions: (dimensions: CanvasDimensions) => Html<HTMLCanvasElement> = (d) => (ctx) =>
  pipe(ctx, setWidth(d.width), IO.chain(setHeight(d.height)))
Enter fullscreen mode Exit fullscreen mode

However, if we continue to examine our code we will see that in many instances we are manually threading the ctx through our API.

Example (stroke a path)

export const strokePath: <A>(f: Render<A>) => Render<A> = (f) => (ctx) =>
  pipe(
    ctx,
    beginPath,
    IO.chain(() => f(ctx)),
    IO.chainFirst(() => pipe(ctx, stroke()))
  )
Enter fullscreen mode Exit fullscreen mode

Example (preserve the canvas context):

export const withContext: <A>(f: Render<A>) => Render<A> = (f) => (ctx) =>
  pipe(
    save(ctx),
    IO.chain(() => f(ctx)),
    IO.chainFirst(() => restore(ctx))
  )
Enter fullscreen mode Exit fullscreen mode

From Getting started with fp-ts:

The purpose of the Reader monad is to avoid threading arguments through multiple functions in order to only get them where they are needed.

What if we could simply chain Render effects? We would need a Monad instance of Render. We know that Render admits a Monad instance because theory tells us that

Reader<R, M<A>>
Enter fullscreen mode Exit fullscreen mode

admits a Monad instance for any effect M as long as M admits a Monad instance. In our case we have

Reader<R, IO<A>>
Enter fullscreen mode Exit fullscreen mode

and, since IO admits a Monad instance, we know that

Render<A> = Reader<CanvasRenderingContext2D, IO<A>> 
Enter fullscreen mode Exit fullscreen mode

admits a Monad instance too.

To create our Monad instance of Render, we can utilize the ReaderIO module from fp-ts-contrib.

ReaderIO<R, A> = Reader<R, IO<A>>
Enter fullscreen mode Exit fullscreen mode

So our effect models now become the following

import * as R from 'fp-ts-contrib/lib/ReaderIO'

export interface Gradient<A> extends R.ReaderIO<CanvasGradient, A> {}

export interface Html<A> extends R.ReaderIO<HTMLCanvasElement, A> {}

export interface Render<A> extends R.ReaderIO<CanvasRenderingContext2D, A> {}
Enter fullscreen mode Exit fullscreen mode

and we can refactor strokePath and withContext from above to

export const strokePath: <A>(f: Render<A>) => Render<A> = (f) =>
  pipe(
    beginPath,
    R.chain(() => f),
    R.chainFirst(() => stroke())
  )

export const withContext: <A>(f: Render<A>) => Render<A> = (f) =>
  pipe(
    save,
    R.chain(() => f),
    R.chainFirst(() => restore)
  )
Enter fullscreen mode Exit fullscreen mode

Putting it all Together

Using the Canvas module from graphics-ts, we can rewrite our example of rendering a house from above as

import { error } from 'fp-ts/lib/Console'
import * as R from 'fp-ts-contrib/lib/ReaderIO'
import * as C from 'graphics-ts/lib/Canvas'
import * as S from 'graphics-ts/lib/Shape'
import { pipe } from 'fp-ts/lib/pipeable'

const canvasId = 'my-house'

const wall = C.strokeRect(S.rect(75, 140, 150, 110))

const door = C.fillRect(S.rect(130, 190, 40, 60))

const roof = C.strokePath(
  pipe(
    C.moveTo(S.point(50, 140)),
    R.chain(() => C.lineTo(S.point(150, 60))),
    R.chain(() => C.lineTo(S.point(250, 140)))
  )
)

C.renderTo(canvasId, () => error(`[ERROR]: Unable to find canvas with id ${canvasId}`))(
  pipe(
    // Set line width
    C.setLineWidth(10),
    R.chain(() => wall),
    R.chain(() => door),
    R.chain(() => roof)
  )
)
Enter fullscreen mode Exit fullscreen mode

The Drawing Module

graphics-ts also provides another layer of abstraction on top of the HTML 5 Canvas API through the Drawing module.

The Drawing module abstracts away the repetitive calls to the HTML Canvas API that are required when using the Canvas module directly and allows for composition of different shapes, styles, and drawings.

If we refactor the example of rendering a house from above one last time using the Drawing module, we get the following

import { error } from 'fp-ts/lib/Console'
import * as M from 'fp-ts/lib/Monoid'
import * as RA from 'fp-ts/lib/ReadonlyArray'
import * as R from 'fp-ts-contrib/lib/ReaderIO'
import * as C from 'graphics-ts/lib/Canvas'
import * as Color from 'graphics-ts/lib/Color'
import * as D from 'graphics-ts/lib/Drawing'
import * as S from 'graphics-ts/lib/Shape'
import { pipe } from 'fp-ts/lib/pipeable'

const canvasId = 'my-house'

const wall = D.outline(
  S.rect(75, 140, 150, 110),
  M.fold(D.monoidOutlineStyle)([D.lineWidth(10), D.outlineColor(Color.black)])
)

const door = D.fill(S.rect(130, 190, 40, 60), D.fillStyle(Color.black))

const roof = D.outline(
  S.path(RA.readonlyArray)([S.point(50, 140), S.point(150, 60), S.point(250, 40)]),
  D.outlineColor(Color.black)
)

C.renderTo(canvasId, () => error(`[ERROR]: Unable to find canvas with id ${canvasId}`))(
  D.render(D.many([wall, door, roof]))
)
Enter fullscreen mode Exit fullscreen mode

Repository

I encourage anyone who is interested in experimenting with the HTML 5 Canvas API to give the library a try!

GitHub logo gcanti / graphics-ts

A porting of purescript-{canvas, drawing} featuring fp-ts

Acknowledgements

I would like to thank @gcanti for giving me the opportunity to work on this rewrite as well as for providing teaching and guidance on use of functional programming theory and the fp-ts ecosystem.

Top comments (0)