All Examples
Canvas Art
Click the canvas to spawn bouncing balls. Demonstrates declarative 2D rendering with Canvas.view, animation-frame Subscriptions, and pointer events translated to canvas-local coordinates.
Canvas
Animation
Subscriptions
/
import {
Array,
Effect,
Match as M,
Number,
Option,
Random,
Schema as S,
pipe,
} from 'effect'
import { Canvas, Command, Runtime, Subscription } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
// MODEL
const CANVAS_WIDTH = 600
const CANVAS_HEIGHT = 400
const BALL_RADIUS_MIN = 8
const BALL_RADIUS_MAX = 24
const BALL_SPEED_MIN = 80
const BALL_SPEED_MAX = 240
const FULL_CIRCLE_RADIANS = Math.PI * 2
const MS_PER_SECOND = 1000
const PALETTE: ReadonlyArray<string> = [
'#ff2d55',
'#ffcc00',
'#34c759',
'#5ac8fa',
'#af52de',
'#ff9500',
]
const FALLBACK_COLOR = '#ffffff'
const Ball = S.Struct({
id: S.Number,
x: S.Number,
y: S.Number,
vx: S.Number,
vy: S.Number,
radius: S.Number,
color: S.String,
})
type Ball = typeof Ball.Type
const Model = S.Struct({
balls: S.Array(Ball),
nextId: S.Number,
isRunning: S.Boolean,
})
type Model = typeof Model.Type
// MESSAGE
const TickedFrame = m('TickedFrame', { deltaTime: S.Number })
const ClickedCanvas = m('ClickedCanvas', { x: S.Number, y: S.Number })
const SpawnedBall = m('SpawnedBall', {
x: S.Number,
y: S.Number,
vx: S.Number,
vy: S.Number,
radius: S.Number,
color: S.String,
})
const ClickedClear = m('ClickedClear')
const ClickedTogglePlay = m('ClickedTogglePlay')
const Message = S.Union([
TickedFrame,
ClickedCanvas,
SpawnedBall,
ClickedClear,
ClickedTogglePlay,
])
type Message = typeof Message.Type
// INIT
const init: Runtime.ProgramInit<Model, Message> = () => [
{ balls: [], nextId: 0, isRunning: true },
[],
]
// COMMAND
const SpawnBall = Command.define(
'SpawnBall',
{ x: S.Number, y: S.Number },
SpawnedBall,
)(({ x, y }) =>
Effect.gen(function* () {
const angle = yield* Random.nextBetween(0, FULL_CIRCLE_RADIANS)
const speed = yield* Random.nextBetween(BALL_SPEED_MIN, BALL_SPEED_MAX)
const radius = yield* Random.nextBetween(BALL_RADIUS_MIN, BALL_RADIUS_MAX)
const colorIndex = yield* Random.nextIntBetween(0, PALETTE.length, {
halfOpen: true,
})
const color = pipe(
PALETTE,
Array.get(colorIndex),
Option.getOrElse(() => FALLBACK_COLOR),
)
return SpawnedBall({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius,
color,
})
}),
)
// UPDATE
const advanceBall =
(deltaSeconds: number) =>
(ball: Ball): Ball => {
const nextX = ball.x + ball.vx * deltaSeconds
const nextY = ball.y + ball.vy * deltaSeconds
const minX = ball.radius
const maxX = CANVAS_WIDTH - ball.radius
const minY = ball.radius
const maxY = CANVAS_HEIGHT - ball.radius
const bouncedX = nextX < minX || nextX > maxX
const bouncedY = nextY < minY || nextY > maxY
return evo(ball, {
x: () => Math.max(minX, Math.min(maxX, nextX)),
y: () => Math.max(minY, Math.min(maxY, nextY)),
vx: vx => (bouncedX ? -vx : vx),
vy: vy => (bouncedY ? -vy : vy),
})
}
const update = (
model: Model,
message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<
readonly [Model, ReadonlyArray<Command.Command<Message>>]
>(),
M.tagsExhaustive({
TickedFrame: ({ deltaTime }) => [
evo(model, {
balls: Array.map(advanceBall(deltaTime / MS_PER_SECOND)),
}),
[],
],
ClickedCanvas: ({ x, y }) => [model, [SpawnBall({ x, y })]],
SpawnedBall: ({ x, y, vx, vy, radius, color }) => [
evo(model, {
balls: balls => [
...balls,
{ id: model.nextId, x, y, vx, vy, radius, color },
],
nextId: Number.increment,
}),
[],
],
ClickedClear: () => [evo(model, { balls: () => [] }), []],
ClickedTogglePlay: () => [
evo(model, { isRunning: running => !running }),
[],
],
}),
)
// SUBSCRIPTION
const SubscriptionDeps = S.Struct({
frame: S.Boolean,
})
const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
Model,
Message
>({
frame: Subscription.animationFrame({
isActive: model => model.isRunning,
toMessage: deltaTime => TickedFrame({ deltaTime }),
}),
})
// VIEW
const h = html<Message>()
const backgroundShape = Canvas.Rect({
x: 0,
y: 0,
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
fill: '#0a0a0f',
})
const ballShape = (ball: Ball): Canvas.Shape =>
Canvas.Circle({
x: ball.x,
y: ball.y,
radius: ball.radius,
fill: ball.color,
})
const sceneShapes = (model: Model): ReadonlyArray<Canvas.Shape> => [
backgroundShape,
...Array.map(model.balls, ballShape),
]
const controlsView = (model: Model): Html =>
h.div(
[h.Class('flex gap-3 mt-4')],
[
h.button(
[
h.Class(
'px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-500',
),
h.OnClick(ClickedTogglePlay()),
],
[model.isRunning ? 'Pause' : 'Play'],
),
h.button(
[
h.Class('px-4 py-2 bg-zinc-700 text-white rounded hover:bg-zinc-600'),
h.OnClick(ClickedClear()),
],
['Clear'],
),
h.p(
[h.Class('px-4 py-2 text-zinc-400 text-sm self-center')],
[`${model.balls.length} balls`],
),
],
)
const view = (model: Model): Document => ({
title: `Canvas Art (${model.balls.length} balls)`,
body: h.div(
[
h.Class(
'flex flex-col items-center justify-center min-h-screen bg-black text-white p-8',
),
],
[
h.h1([h.Class('text-4xl font-bold mb-2')], ['Canvas Art']),
h.p(
[h.Class('text-zinc-400 mb-6')],
['Click the canvas to spawn a ball.'],
),
Canvas.view<Message>({
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
shapes: sceneShapes(model),
className: 'rounded-lg shadow-2xl cursor-crosshair',
onPointerDown: ({ x, y }) => ClickedCanvas({ x, y }),
}),
controlsView(model),
],
),
})
// RUN
const program = Runtime.makeProgram({
Model,
init,
update,
view,
subscriptions,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
container: document.getElementById('root')!,
devTools: {
Message,
},
})
Runtime.run(program)