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, Number, Option, Random, Schema, pipe } from 'effect'
import { Canvas, Command, Runtime, Subscription, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { modifyFields } from 'foldkit/struct'
import { Button } from '@foldkit/ui'
// 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 = Schema.Struct({
id: Schema.Number,
x: Schema.Number,
y: Schema.Number,
vx: Schema.Number,
vy: Schema.Number,
radius: Schema.Number,
color: Schema.String,
})
type Ball = typeof Ball.Type
export const Model = Schema.Struct({
balls: Schema.Array(Ball),
nextId: Schema.Number,
isRunning: Schema.Boolean,
})
export type Model = typeof Model.Type
// MESSAGE
export const Message = defineMessageUnion({
TickedFrame: { deltaTime: Schema.Number },
ClickedCanvas: { x: Schema.Number, y: Schema.Number },
CompletedGenerateBall: {
x: Schema.Number,
y: Schema.Number,
vx: Schema.Number,
vy: Schema.Number,
radius: Schema.Number,
color: Schema.String,
},
ClickedClear: {},
ClickedTogglePlay: {},
})
export type Message = typeof Message.Type
// INIT
export const init: Runtime.ApplicationInit<Model, Message> = () => ({
model: { balls: [], nextId: 0, isRunning: true },
})
// COMMAND
export const GenerateBall = Command.define('GenerateBall', {
args: { x: Schema.Number, y: Schema.Number },
messages: [Message.CompletedGenerateBall],
execute: ({ 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 Message.CompletedGenerateBall({
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 modifyFields(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),
})
}
export const update = (model: Model, message: Message) =>
Message.match<Update.Return<Model, Message>>(message, {
TickedFrame: ({ deltaTime }) => ({
model: modifyFields(model, {
balls: Array.map(advanceBall(deltaTime / MS_PER_SECOND)),
}),
}),
ClickedCanvas: ({ x, y }) => ({
model,
commands: [GenerateBall({ x, y })],
}),
CompletedGenerateBall: ({ x, y, vx, vy, radius, color }) => ({
model: modifyFields(model, {
balls: Array.append({
id: model.nextId,
x,
y,
vx,
vy,
radius,
color,
}),
nextId: Number.increment,
}),
}),
ClickedClear: () => ({ model: modifyFields(model, { balls: () => [] }) }),
ClickedTogglePlay: () => ({
model: modifyFields(model, { isRunning: running => !running }),
}),
})
// SUBSCRIPTION
export const subscriptions = Subscription.make<Model, Message>()(_entry => ({
frame: Subscription.animationFrame({
isActive: model => model.isRunning,
toMessage: deltaTime => Message.TickedFrame({ deltaTime }),
}),
}))
// VIEW
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, h: HtmlBuilder<Message>): Html =>
h.div(
[h.Class('flex gap-3 mt-4')],
[
Button.view(
{
onClick: Message.ClickedTogglePlay(),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'min-w-20 px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-500',
),
],
[model.isRunning ? 'Pause' : 'Play'],
),
},
h,
),
Button.view(
{
onClick: Message.ClickedClear(),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'min-w-20 px-4 py-2 bg-zinc-700 text-white rounded hover:bg-zinc-600',
),
],
['Clear'],
),
},
h,
),
h.p(
[h.Class('px-4 py-2 text-zinc-400 text-sm self-center')],
[
h.span(
[h.Class('inline-block tabular-nums text-right min-w-8')],
[`${model.balls.length}`],
),
' balls',
],
),
],
)
export const view = (model: Model, h: HtmlBuilder<Message>): 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(
{
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
shapes: sceneShapes(model),
className: 'rounded-lg shadow-2xl cursor-crosshair',
onPointerDown: ({ x, y }) => Message.ClickedCanvas({ x, y }),
},
h,
),
controlsView(model, h),
],
),
})