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>
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();
}
}
Output
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
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))
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)))
)
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)
)
)
)
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>
CanvasRenderingContext2D
(ctx: CanvasRenderingContext2D) => IO.IO<CanvasRenderingContext2D>
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>> {}
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)))
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()))
)
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))
)
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>>
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>>
and, since IO
admits a Monad
instance, we know that
Render<A> = Reader<CanvasRenderingContext2D, IO<A>>
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>>
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> {}
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)
)
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)
)
)
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]))
)
Repository
I encourage anyone who is interested in experimenting with the HTML 5 Canvas API to give the library a try!
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)