Art
Twenty-eight pieces drawn with Canvas and DitherField, in warp's own inks. They follow your theme and the dial, three at most move at a time, and the rest wait as stills.
Dither fields
Tones and a few inks. The picture is whatever the threshold leaves behind.
Warp Beam
Tense threads run down the loom and catch the light where they cross a slow beam. Drag through them to pluck.
Code
import { DitherField } from '@weft-js/warp'import { noise, type ArtProps, type Meta } from './shared'import code from './warp-beam.tsx?raw'export const meta: Meta = { name: 'Warp Beam', description: 'Tense threads run down the loom and catch the light where they cross a slow beam. Drag through them to pluck.', kind: 'field', code, wide: true }const { sin, exp, PI, floor } = Mathconst SIZE = 4096, WAVE = Float32Array.from({ length: SIZE }, (_, i) => sin((i / SIZE) * PI * 2)), wave = (v: number) => WAVE[(v * (SIZE / (PI * 2)) + SIZE * 64) & (SIZE - 1)]!const K = 66, TENSION = Array.from({ length: 512 }, (_, i) => 0.4 + 0.6 * noise(i * 0.61, 2.1) ** 1.2)// Each row shares its beam, so that is worked out once a row, not once a cell.let row = -1, stamp = -1, beam = 0, width = 0function threads(x: number, y: number, t: number) { if (y !== row || t !== stamp) { row = y stamp = t beam = 1.3 + 0.7 * sin(t * 0.13) + 0.3 * sin(y * 1.6 + t * 0.21) width = 0.9 + 0.35 * sin(y * 2.4 - t * 0.17) } const phase = x * K + 2.4 * wave(2 * y + x * 1.3 + t * 0.5) + 0.8 * wave(5 * y - x * 2 - t * 0.3), s = wave(phase), s2 = s * s const d = x - beam, light = exp(-d * d * width) return s2 * s2 * TENSION[floor(phase / PI) & 511]! * (0.42 + 0.68 * light) + 0.34 * light}export default function WarpBeam({ live = true }: ArtProps) { return <DitherField fill kind={threads} ramp="neutral" spark="accent" pointer="pull" cell={3} gain={1.3} seed={3} speed={live ? 1 : 0} label="Vertical threads lit by a slow beam of light" />}Tide Table
Five horizons, each a little closer and heavier than the last. Good for the seam between two sections.
Code
import { DitherField } from '@weft-js/warp'import { smooth, type ArtProps, type Meta } from './shared'import code from './tide-table.tsx?raw'export const meta: Meta = { name: 'Tide Table', description: 'Five horizons, each a little closer and heavier than the last. Good for the seam between two sections.', kind: 'field', code, wide: true }const { sin } = Math, BANDS = 6, SLOTS = 320, SPAN = 4const crests = new Float32Array(BANDS * SLOTS)let stamp = NaN// Every cell in a column shares its crests, so they go in a table once per frame and the cells only compare against it.function tabulate(t: number) { stamp = t for (let i = 0; i < BANDS; i++) for (let n = 0; n < SLOTS; n++) { const x = (n / SLOTS) * SPAN crests[i * SLOTS + n] = 0.14 + i * 0.145 + sin(x * (1.1 + i * 0.55) + t * (0.18 + i * 0.05) + i * 2.1) * (0.016 + i * 0.008) + sin(x * (3.1 + i * 0.9) - t * 0.11 + i) * 0.008 }}function horizons(x: number, y: number, t: number) { if (t !== stamp) tabulate(t) const n = (x / SPAN) * SLOTS | 0 let tone = 0 for (let i = 0; i < BANDS; i++) { const under = y - crests[i * SLOTS + n]! if (under > 0) tone = (0.66 + i * 0.07) * (0.62 + 0.38 * smooth(0.12, 0, under)) + 0.16 * smooth(0.012, 0, under) } return tone}export default function TideTable({ live = true }: ArtProps) { return <DitherField fill kind={horizons} ramp={['none', '--warp-color-accent-soft', '--warp-color-accent-border', '--warp-color-accent-text']} pattern="blue" fade="top" cell={3} gain={1.25} seed={9} speed={live ? 1 : 0} label="Five stacked horizons, thinning toward the top" />}Moiré Loom
Two gratings, a few degrees apart, and everything else is the eye. Click to drop a ring through them.
Code
import { DitherField } from '@weft-js/warp'import { type ArtProps, type Meta } from './shared'import code from './moire-loom.tsx?raw'export const meta: Meta = { name: 'Moiré Loom', description: 'Two gratings, a few degrees apart, and everything else is the eye. Click to drop a ring through them.', kind: 'field', code }const { sin, cos } = Mathfunction beats(x: number, y: number, t: number) { const a = 0.06 + sin(t * 0.07) * 0.03, c = cos(a), s = sin(a), k = 118 const lift = sin(x * 1.1 + t * 0.1) * 0.5 + sin(y * 1.7 - t * 0.08) * 0.5 const one = 0.5 + 0.5 * sin(y * k + lift), two = 0.5 + 0.5 * sin((x * s + y * c) * k * 1.04 - lift + t * 0.12) return Math.sqrt(one * two) * 0.96}export default function MoireLoom({ live = true }: ArtProps) { return <DitherField fill kind={beats} spark="accent" ramp="neutral" spread={0.5} pointer="shockwave" cell={2} gain={1.2} seed={21} speed={live ? 1 : 0} label="Two line gratings beating against each other" />}Turing Cloth
Two chemicals feed and kill each other, and a labyrinth grows out of it, the way a leopard gets its spots. Click to seed.
Code
import { DitherField } from '@weft-js/warp'import { rng, clamp, type ArtProps, type Meta } from './shared'import code from './turing-cloth.tsx?raw'export const meta: Meta = { name: 'Turing Cloth', description: 'Two chemicals feed and kill each other, and a labyrinth grows out of it, the way a leopard gets its spots. Click to seed.', kind: 'field', code }const W = 150, H = 94, FEED = 0.037, KILL = 0.06export default function TuringCloth({ live = true }: ArtProps) { let a = new Float32Array(W * H).fill(1), b = new Float32Array(W * H), a2 = new Float32Array(W * H), b2 = new Float32Array(W * H) let warm = false, stamp = -1 const drop = (cx: number, cy: number, r: number) => { for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) if (x * x + y * y <= r * r) { const i = ((cy + y + H) % H) * W + ((cx + x + W) % W); a[i] = 0.5; b[i] = 0.5 } } function step(n: number) { for (let s = 0; s < n; s++) { for (let y = 0, i = 0; y < H; y++) { const up = (y === 0 ? H - 1 : y - 1) * W, down = (y === H - 1 ? 0 : y + 1) * W, here = y * W for (let x = 0; x < W; x++, i++) { const l = x === 0 ? W - 1 : x - 1, r = x === W - 1 ? 0 : x + 1 const A = a[i]!, B = b[i]!, abb = A * B * B a2[i] = A + (a[here + l]! + a[here + r]! + a[up + x]! + a[down + x]! - 4 * A) * 0.2 - abb + FEED * (1 - A) b2[i] = B + (b[here + l]! + b[here + r]! + b[up + x]! + b[down + x]! - 4 * B) * 0.1 + abb - (KILL + FEED) * B } } ;[a, a2] = [a2, a] ;[b, b2] = [b2, b] } } const seed = () => { const r = rng(11); for (let i = 0; i < 46; i++) drop((r() * W) | 0, (r() * H) | 0, 2 + ((r() * 3) | 0)); step(1000); warm = true } // A field is a pure function of place and time, so a new `t` is what advances the reaction. let aspect = 1.6 const cloth = (x: number, y: number, t: number) => { if (!warm) seed() if (t !== stamp) { stamp = t; step(2) } if (x > aspect) aspect = x const fx = (x / aspect) * (W - 1), fy = y * (H - 1), ix = fx | 0, iy = fy | 0, kx = fx - ix, ky = fy - iy const i = iy * W + ix, j = i + 1, m = iy < H - 1 ? i + W : i, n = m + 1 return clamp(((b[i]! * (1 - kx) + b[j]! * kx) * (1 - ky) + (b[m]! * (1 - kx) + b[n]! * kx) * ky) * 2.9) } const press = (e: PointerEvent) => { const r = (e.currentTarget as HTMLElement).getBoundingClientRect() drop(((e.clientX - r.left) / r.width * W) | 0, ((e.clientY - r.top) / r.height * H) | 0, 3) } return <DitherField fill kind={cloth} cell={3} gain={1.45} fps={30} seed={1} speed={live ? 1 : 0} onPointerDown={press} label="A labyrinth grown by a reaction and diffusion" />}Survey
A contour map of country that doesn't exist. Bring the pointer close and a new hill comes up under it.
Code
import { DitherField } from '@weft-js/warp'import { clamp, fbm, smooth, type ArtProps, type Meta } from './shared'import code from './survey.tsx?raw'export const meta: Meta = { name: 'Survey', description: 'A contour map of country that doesn\'t exist. Bring the pointer close and a new hill comes up under it.', kind: 'field', code }const W = 96, H = 60, RINGS = 15export default function Survey({ live = true }: ArtProps) { const hills = new Float32Array(W * H) let stamp = NaN, px = -1, py = -1, lift = 0, aspect = 1.6 function survey(t: number) { stamp = t lift = px < 0 ? Math.max(0, lift - 0.015) : Math.min(1, lift + 0.03) for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) { const u = (x / (W - 1)) * aspect * 1.5, v = (y / (H - 1)) * 1.5 let h = fbm(u + t * 0.008, v - t * 0.005, 4) h = (h - 0.28) * 1.75 if (lift) { const dx = x / (W - 1) - px, dy = y / (H - 1) - py; h += 0.32 * lift * Math.exp(-(dx * dx * 2.5 + dy * dy) * 18) } hills[i] = h } } const map = (x: number, y: number, t: number) => { if (x > aspect) aspect = x if (t !== stamp) survey(t) const fx = clamp(x / aspect) * (W - 1.001), fy = y * (H - 1.001), ix = fx | 0, iy = fy | 0, kx = fx - ix, ky = fy - iy, i = iy * W + ix const h = (hills[i]! * (1 - kx) + hills[i + 1]! * kx) * (1 - ky) + (hills[i + W]! * (1 - kx) + hills[i + W + 1]! * kx) * ky // distance to the nearest contour, divided by the slope, so a line keeps its weight on steep ground and on flat const sx = Math.abs(hills[i + 1]! - hills[i]!) * (W - 1), sy = Math.abs(hills[i + W]! - hills[i]!) * (H - 1), slope = Math.hypot(sx, sy) * RINGS + 0.4 const band = h * RINGS, f = band - Math.floor(band), line = smooth(1.5, 0.7, (Math.min(f, 1 - f) / slope) * 300) const index = (Math.floor(band + 0.5) + 400) % 5 === 0 ? 1 : 0.62 return Math.max(line * index, 0.24 * smooth(0.5, 1, h)) } const place = (e: PointerEvent) => { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); px = (e.clientX - r.left) / r.width; py = (e.clientY - r.top) / r.height } return <DitherField fill kind={map} ramp={['none', '--warp-color-border-strong', '--warp-color-text-subtle', '--warp-color-accent-text']} cell={2} spread={0.4} gain={1.6} seed={40} speed={live ? 1 : 0} onPointerMove={place} onPointerLeave={() => { px = -1 }} label="A contour map of invented hills" />}Woven Word
A word pulled out of the cloth from left to right, with a flash where each thread lands. It stays real text for readers and search.
Code
import { DitherGlyph } from '@weft-js/warp'import { type ArtProps, type Meta } from './shared'import code from './woven-word.tsx?raw'export const meta: Meta = { name: 'Woven Word', description: 'A word pulled out of the cloth from left to right, with a flash where each thread lands. It stays real text for readers and search.', kind: 'field', code, wide: true }export default function WovenWord({ live = true }: ArtProps) { return <DitherGlyph text="weft" reveal={1.4} pointer="ripple" spark="neutral" cell={3} fit={0.8} weight={500} speed={live ? 1 : 0} ratio="auto" style="position:absolute;inset:0;block-size:100%" />}Darkroom
One picture through every algorithm warp has. Drag the divider to see what the dither kept.
Code
import { Dither, Segmented, type Algorithm } from '@weft-js/warp'import { state } from '@weft-js/core'import { fbm, clamp, mix, smooth, type ArtProps, type Meta } from './shared'import code from './darkroom.tsx?raw'export const meta: Meta = { name: 'Darkroom', description: 'One picture through every algorithm warp has. Drag the divider to see what the dither kept.', kind: 'field', code, wide: true, rest: true }const ALGORITHMS = [{ value: 'atkinson', label: 'Atkinson' }, { value: 'floyd', label: 'Floyd' }, { value: 'bayer8', label: 'Bayer' }, { value: 'blue', label: 'Blue' }, { value: 'halftone', label: 'Halftone' }]const CELLS = [{ value: '2', label: '2' }, { value: '3', label: '3' }, { value: '5', label: '5' }]// There is no photograph in the repo, so the print is a dusk painted once here: sky, sun, three ridges, mist.function dusk() { const W = 640, H = 300, c = document.createElement('canvas') c.width = W c.height = H const g = c.getContext('2d')!, px = g.createImageData(W, H), sky = [[0.98, 0.72, 0.5], [0.72, 0.42, 0.52], [0.16, 0.16, 0.34]] for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i += 4) { const v = y / H, u = x / W, up = clamp((0.72 - v) / 0.72), k = up * 2, a = sky[k < 1 ? 0 : 1]!, b = sky[k < 1 ? 1 : 2]!, m = k < 1 ? k : k - 1 const cloud = smooth(0.52, 0.8, fbm(u * 5 + 3, v * 16, 5)) * 0.5 let r = mix(a[0]!, b[0]!, m) + cloud * 0.25, gr = mix(a[1]!, b[1]!, m) + cloud * 0.16, bl = mix(a[2]!, b[2]!, m) + cloud * 0.12 const d = Math.hypot((u - 0.66) * 2.13, v - 0.52), sun = smooth(0.1, 0.092, d), glow = Math.exp(-d * d * 7) * 0.6 r += glow + sun; gr += glow * 0.7 + sun * 0.9; bl += glow * 0.35 + sun * 0.7 for (let n = 0; n < 3; n++) { const top = 0.58 + n * 0.12 + (fbm(u * (3 + n * 1.5) + n * 7, n * 3.3, 5) - 0.5) * (0.42 - n * 0.06) if (v > top) { const haze = 0.42 - n * 0.2 + (v - top) * -0.1, s = 0.1 - n * 0.025; r = mix(s, 0.86, haze); gr = mix(s, 0.56, haze); bl = mix(s + 0.04, 0.55, haze) } } px.data[i] = r * 255; px.data[i + 1] = gr * 255; px.data[i + 2] = bl * 255; px.data[i + 3] = 255 } g.putImageData(px, 0, 0) return c.toDataURL('image/png')}export default function Darkroom(_: ArtProps) { const split = state(52), algorithm = state('atkinson'), cell = state('3'), print = state<string | undefined>(typeof document === 'undefined' ? undefined : dusk()) // oxlint-disable-next-line no-unassigned-vars -- the compiler assigns it through ref let host!: HTMLDivElement const slide = (e: PointerEvent) => { if (!e.buttons && e.type !== 'pointerdown') return if ((e.target as Element).closest('[data-art="controls"]')) return const r = host.getBoundingClientRect() split.value = clamp((e.clientX - r.left) / r.width, 0.02, 0.98) * 100 } return ( <div data-art="pierce" data-art-darkroom="" ref={host} style={`--split:${split.value}%`} onPointerDown={slide} onPointerMove={slide}> <img src={print.value} alt="" /> <div data-part="print">{print.value && <Dither src={print.value} alt="A dusk over three ridges" algorithm={algorithm.value as Algorithm} cell={+cell.value} ramp="neutral" inks={4} fill />}</div> <i data-part="divider"></i> <div data-art="controls"> <Segmented label="Algorithm" value={algorithm.value} onChange={(v: string) => { algorithm.value = v }} options={ALGORITHMS} /> <Segmented label="Cell size" value={cell.value} onChange={(v: string) => { cell.value = v }} options={CELLS} /> </div> </div> )}Ramp Wall
Every ramp through every pattern, as plates. Click one and its props are on your clipboard.
Code
import { Dither, type Algorithm, type Ramp } from '@weft-js/warp'import { state } from '@weft-js/core'import { type ArtProps, type Meta } from './shared'import code from './ramp-wall.tsx?raw'export const meta: Meta = { name: 'Ramp Wall', description: 'Every ramp through every pattern, as plates. Click one and its props are on your clipboard.', kind: 'field', code, wide: true, rest: true }const RAMPS = ['accent', 'neutral', 'positive', 'caution', 'critical', 'info', 'highlight'] as const satisfies readonly Ramp[]const PATTERNS: Algorithm[] = ['bayer2', 'bayer4', 'bayer8', 'blue', 'halftone']export default function RampWall(_: ArtProps) { const copied = state('') const copy = (prop: string) => { void navigator.clipboard?.writeText(prop); copied.value = `${prop} copied` } return ( <div data-art="pierce" data-art-wall=""> {RAMPS.map(ramp => PATTERNS.map(algorithm => { const prop = `<Dither ramp="${ramp}" algorithm="${algorithm}" />` return <button key={`${ramp}-${algorithm}`} type="button" aria-label={`${ramp}, ${algorithm}`} onClick={() => copy(prop)}><Dither ramp={ramp} algorithm={algorithm} cell={3} inks={4} gradient={{ angle: 100 }} fill /></button> }))} <p data-art="readout" role="status">{copied.value}</p> </div> )}Generative
Rules, a seed and hairlines. Each of these could be printed and hung.
Shuttle
One stroke crosses the warps and leaves one woven row behind it. It never rewinds. Hover to hurry it along.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { state } from '@weft-js/core'import { inks, type ArtProps, type Meta } from './shared'import code from './shuttle.tsx?raw'export const meta: Meta = { name: 'Shuttle', description: 'One stroke crosses the warps and leaves one woven row behind it. It never rewinds. Hover to hurry it along.', kind: 'generative', code, wide: true }const PITCH = 5, ROW = 4const colours = inks({ warp: '--warp-color-border-strong', soft: '--warp-color-accent-border', ink: '--warp-color-accent-text', deep: '--warp-color-text', sweep: '--warp-color-sweep', ground: '--warp-color-page' })/** Which of the weft's colours row `r` takes: broad bands, like a blanket's stripes. */const band = (r: number) => { const k = (r % 64) / 64; return k < 0.44 ? 0 : k < 0.5 ? 2 : k < 0.72 ? 1 : k < 0.75 ? 2 : k < 0.94 ? 0 : 1 }export default function Shuttle({ live = true }: ArtProps) { let cloth: HTMLCanvasElement | undefined, g: CanvasRenderingContext2D, row = 0, across = 0, w = 0, h = 0, dpr = 1 const hurry = state(false) const rows = () => Math.floor((h - 8) / ROW) /** One finished row: a 2/2 twill, so the weft lies over two warps and under two, stepping one along each row. */ function weave(r: number, y: number, c: ReturnType<typeof colours>) { const count = Math.ceil(w / PITCH) g.fillStyle = c.warp g.fillRect(0, y, w, ROW) g.fillStyle = [c.soft, c.ink, c.deep][band(r)]! for (let i = 0; i < count; i++) if ((i + r) & 2) g.fillRect(i * PITCH, y, PITCH - 0.5, ROW) } function warps(ctx: CanvasRenderingContext2D, c: ReturnType<typeof colours>) { ctx.fillStyle = c.warp for (let x = PITCH / 2; x < w; x += PITCH) ctx.fillRect(x - 0.5, 0, 1, h) } function rebuild(c: ReturnType<typeof colours>) { cloth ??= document.createElement('canvas') cloth.width = w * dpr cloth.height = h * dpr g = cloth.getContext('2d')! g.setTransform(dpr, 0, 0, dpr, 0, 0) const n = rows() for (let i = 0; i < Math.min(row, n); i++) weave(row - 1 - i, (Math.min(row, n) - 1 - i) * ROW + 4, c) } const draw = ({ ctx, width, height, dpr: scale, dt, tier, still }: Frame) => { const c = colours(ctx.canvas) if (width !== w || height !== h || !cloth || scale !== dpr) { w = width; h = height; dpr = scale if (!row) { row = Math.floor(rows() * 0.8); across = 0.58 } rebuild(c) } if (!still) across += dt * (hurry.value ? 2.4 : 0.34 + tier.speed * 0.4) while (across >= 1) { across -= 1 const n = rows() if (row >= n) { g.globalCompositeOperation = 'copy'; g.setTransform(1, 0, 0, 1, 0, -ROW * dpr); g.drawImage(cloth!, 0, 0); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.globalCompositeOperation = 'source-over' } weave(row, Math.min(row, n - 1) * ROW + 4, c) row++ } ctx.clearRect(0, 0, w, h) warps(ctx, c) ctx.drawImage(cloth!, 0, 0, w, h) const n = rows(), y = Math.min(row, n - 1) * ROW + 4, right = row & 1, x = (right ? 1 - across : across) * w, from = right ? x : 0, to = right ? w : x // the row in progress, laid under the shuttle's tail ctx.fillStyle = [c.soft, c.ink, c.deep][band(row)]! for (let i = Math.floor(from / PITCH); i * PITCH < to; i++) if ((i + row) & 2) ctx.fillRect(i * PITCH, y, PITCH - 0.5, ROW) const tail = right ? x + 90 : x - 90, wash = ctx.createLinearGradient(tail, 0, x, 0) wash.addColorStop(0, 'transparent') wash.addColorStop(1, c.sweep) ctx.fillStyle = wash ctx.fillRect(Math.min(tail, x), y + 1, 90, 2) ctx.fillStyle = c.sweep ctx.fillRect(x - 11, y - 1, 22, 6) } return <Canvas draw={draw} animate={live} onTheme={() => { colours.reset(); cloth = undefined }} onPointerEnter={() => { hurry.value = true }} onPointerLeave={() => { hurry.value = false }} label="A shuttle weaving a striped twill, one row at a time" />}Curl Threads
Six hundred strands combed around eddies you can't see. Move the pointer in and it becomes one more.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { fbm, inks, rng, type ArtProps, type Meta } from './shared'import code from './curl-threads.tsx?raw'export const meta: Meta = { name: 'Curl Threads', description: 'Six hundred strands combed around eddies you can\'t see. Move the pointer in and it becomes one more.', kind: 'generative', code }const COUNT = 600, STEP = 1.1const colours = inks({ line: '--warp-color-text-subtle', accent: '--warp-color-accent-text' })export default function CurlThreads({ live = true }: ArtProps) { const x = new Float32Array(COUNT), y = new Float32Array(COUNT), age = new Float32Array(COUNT), random = rng(4) let w = 0, h = 0, drift = 0, prepared = false const respawn = (i: number) => { x[i] = random() * w; y[i] = random() * h; age[i] = 90 + random() * 200 } const potential = (px: number, py: number) => fbm(px * 0.0052 + drift, py * 0.0052 - drift * 0.5, 2) function walk(ctx: CanvasRenderingContext2D, from: number, to: number, px: number, py: number, over: boolean) { ctx.beginPath() for (let i = from; i < to; i++) { const e = 2.5, n1 = potential(x[i]!, y[i]! + e), n2 = potential(x[i]!, y[i]! - e), n3 = potential(x[i]! + e, y[i]!), n4 = potential(x[i]! - e, y[i]!) let vx = (n1 - n2) * 130, vy = (n4 - n3) * 130 if (over) { const dx = x[i]! - px, dy = y[i]! - py, d = dx * dx + dy * dy + 900, k = 2400 / d; vx += -dy * k * 0.05; vy += dx * k * 0.05 } const nx = x[i]! + vx * STEP, ny = y[i]! + vy * STEP if (--age[i]! < 0 || nx < -8 || ny < -8 || nx > w + 8 || ny > h + 8) { respawn(i); continue } ctx.moveTo(x[i]!, y[i]!) ctx.lineTo(nx, ny) x[i] = nx y[i] = ny } ctx.stroke() } function strands(ctx: CanvasRenderingContext2D, c: ReturnType<typeof colours>, px: number, py: number, over: boolean) { ctx.lineWidth = 0.7 ctx.strokeStyle = c.line walk(ctx, 0, COUNT - 60, px, py, over) ctx.lineWidth = 1 ctx.strokeStyle = c.accent walk(ctx, COUNT - 60, COUNT, px, py, over) } const draw = ({ ctx, width, height, still, tier, pointer, dt }: Frame) => { const c = colours(ctx.canvas) if (width !== w || height !== h || !prepared) { w = width; h = height for (let i = 0; i < COUNT; i++) respawn(i) ctx.clearRect(0, 0, w, h) ctx.globalAlpha = 0.85 for (let n = 0; n < 46; n++) strands(ctx, c, 0, 0, false) ctx.globalAlpha = 1 prepared = true } if (still) return drift += dt * 0.012 * tier.speed ctx.globalCompositeOperation = 'destination-out' ctx.fillStyle = 'rgb(0 0 0 / 0.022)' ctx.fillRect(0, 0, w, h) ctx.globalCompositeOperation = 'source-over' ctx.globalAlpha = 0.85 strands(ctx, c, pointer.x, pointer.y, pointer.in) ctx.globalAlpha = 1 } return <Canvas draw={draw} animate={live} fps={30} pointer onTheme={() => { colours.reset(); prepared = false }} label="Long strands flowing around invisible eddies" />}Truchet Cloth
A single square, two arcs, turned or not. One tile at a time the knot rewrites itself. Hover to turn the nearest.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { inks, rng, smooth, type ArtProps, type Meta } from './shared'import code from './truchet-cloth.tsx?raw'export const meta: Meta = { name: 'Truchet Cloth', description: 'A single square, two arcs, turned or not. One tile at a time the knot rewrites itself. Hover to turn the nearest.', kind: 'generative', code }const SIZE = 30, TWEEN = 0.5, { PI } = Mathconst colours = inks({ line: '--warp-color-text-subtle', ground: '--warp-color-accent-soft', accent: '--warp-color-sweep', soft: '--warp-color-page' })export default function TruchetCloth({ live = true }: ArtProps) { let cols = 0, rows = 0, turns = new Float32Array(0), targets = new Uint8Array(0), active = -1, phase = 0, wait = 0, last = -1 const random = rng(3) function tile(ctx: CanvasRenderingContext2D, i: number, r: number) { const x = (i % cols) * SIZE + SIZE / 2, y = Math.floor(i / cols) * SIZE + SIZE / 2, c = Math.cos(r), s = Math.sin(r), h = SIZE / 2 // the arcs' centres, turned by hand: a path is cheaper than a transform per tile const ax = x - h * c + h * s, ay = y - h * s - h * c, bx = x + h * c - h * s, by = y + h * s + h * c ctx.moveTo(ax + h * c, ay + h * s); ctx.arc(ax, ay, h, r, r + PI / 2) ctx.moveTo(bx - h * c, by - h * s); ctx.arc(bx, by, h, r + PI, r + PI * 1.5) } function ribbon(ctx: CanvasRenderingContext2D, list: number[], width: number, ink: string, ground: string) { ctx.lineCap = 'butt' for (const [wide, colour] of [[width, ink], [width - 2, ground]] as const) { ctx.beginPath() for (const i of list) tile(ctx, i, turns[i]!) ctx.lineWidth = wide ctx.strokeStyle = colour ctx.stroke() } } const all: number[] = [] const draw = ({ ctx, width, height, dt, still, pointer, tier }: Frame) => { const c = colours(ctx.canvas), nc = Math.ceil(width / SIZE), nr = Math.ceil(height / SIZE) if (nc !== cols || nr !== rows) { cols = nc; rows = nr turns = new Float32Array(cols * rows) targets = new Uint8Array(cols * rows) all.length = 0 for (let i = 0; i < turns.length; i++) { targets[i] = random() < 0.5 ? 0 : 1; turns[i] = targets[i]! * (PI / 2); all.push(i) } } const over = pointer.in ? Math.floor(pointer.y / SIZE) * cols + Math.floor(pointer.x / SIZE) : -1 if (active < 0 && over >= 0 && over !== last && over < turns.length) { active = last = over; phase = 0 } if (!still && active < 0 && (wait -= dt * tier.speed) < 0) { active = (random() * turns.length) | 0; phase = 0; wait = 0.14 } if (active >= 0) { phase += dt / TWEEN if (phase >= 1) { targets[active]! ^= 1; turns[active] = targets[active]! * (PI / 2); active = -1 } else turns[active] = targets[active]! * (PI / 2) + smooth(0, 1, phase) * (PI / 2) } ctx.clearRect(0, 0, width, height) ribbon(ctx, all, 12, c.line, c.ground) if (active >= 0) { ctx.fillStyle = c.soft; ctx.fillRect((active % cols) * SIZE, Math.floor(active / cols) * SIZE, SIZE, SIZE); ribbon(ctx, [active], 12, c.accent, c.ground) } return active >= 0 } return <Canvas draw={draw} animate={live} fps={30} pointer onTheme={colours.reset} label="A knot of ribbons that rewrites itself tile by tile" />}Bobbin
Every dot sits 137.5° past the last, the angle sunflowers use, and the disc breathes. Scroll and it turns with you.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { onCleanup, onMount } from '@weft-js/core'import { inks, smooth, type ArtProps, type Meta } from './shared'import code from './bobbin.tsx?raw'export const meta: Meta = { name: 'Bobbin', description: 'Every dot sits 137.5° past the last, the angle sunflowers use, and the disc breathes. Scroll and it turns with you.', kind: 'generative', code }const COUNT = 1700, GOLDEN = (137.508 * Math.PI) / 180const colours = inks({ near: '--warp-color-text', mid: '--warp-color-accent-text', far: '--warp-color-accent-border' })export default function Bobbin({ live = true }: ArtProps) { let scroll = 0 onMount(() => { const on = () => { scroll = scrollY } addEventListener('scroll', on, { passive: true }) onCleanup(() => removeEventListener('scroll', on)) }) const draw = ({ ctx, width, height, time, tier }: Frame) => { const c = colours(ctx.canvas), cx = width / 2, cy = height / 2, unit = (Math.min(width, height) * 0.49) / Math.sqrt(COUNT), t = 4 + time * (0.25 + tier.speed * 0.35) + scroll * 0.0025 ctx.clearRect(0, 0, width, height) for (let band = 0; band < 3; band++) { ctx.fillStyle = [c.far, c.mid, c.near][band]! ctx.beginPath() for (let n = band; n < COUNT; n += 3) { const a = n * GOLDEN + t * 0.06, r = unit * Math.sqrt(n), wave = Math.sin(n * 0.02 + t) * 0.5 + 0.5, breathe = Math.sin(Math.sqrt(n) * 0.55 - t * 1.3) * 0.5 + 0.5 const size = 0.5 + (wave * 0.4 + breathe * 0.6) ** 1.8 * unit * 0.6 * smooth(0, 90, n + 18) const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r ctx.moveTo(x + size, y) ctx.arc(x, y, size, 0, 6.2832) } ctx.fill() } } return <Canvas draw={draw} animate={live} onTheme={colours.reset} label="A phyllotaxis disc of dots that swells and settles" />}Long Thread
One unbroken Hilbert curve, 4,096 points long, that visits every square of the card and never crosses itself. It lays itself down again each time you come back.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { state, watch } from '@weft-js/core'import { inks, type ArtProps, type Meta } from './shared'import code from './long-thread.tsx?raw'export const meta: Meta = { name: 'Long Thread', description: 'One unbroken Hilbert curve, 4,096 points long, that visits every square of the card and never crosses itself. It lays itself down again each time you come back.', kind: 'generative', code }const ORDER = 6, SIDE = 1 << ORDER, TOTAL = SIDE * SIDE, RATE = 760const colours = inks({ line: '--warp-color-text-subtle', head: '--warp-color-sweep', tail: '--warp-color-accent-text' })/** The classic index-to-point walk, by rotating quadrants. */function point(d: number) { let rx: number, ry: number, x = 0, y = 0, t = d for (let s = 1; s < SIDE; s *= 2) { rx = 1 & (t / 2) ry = 1 & (t ^ rx) if (ry === 0) { if (rx === 1) { x = s - 1 - x; y = s - 1 - y } [x, y] = [y, x] } x += s * rx y += s * ry t = (t / 4) | 0 } return [x, y] as const}const points = Array.from({ length: TOTAL }, (_, d) => point(d))export default function LongThread({ live = true }: ArtProps) { const running = state(true) let shown = 0, sheet: HTMLCanvasElement | undefined, sizedFor = '' watch(() => live, on => { if (on) { shown = 0; sizedFor = ''; running.value = true } }) const place = (width: number, height: number) => { const m = 16, ux = (width - m * 2) / (SIDE - 1), uy = (height - m * 2) / (SIDE - 1) return (d: number) => [m + points[d]![0] * ux, m + points[d]![1] * uy] as const } const draw = ({ ctx, width, height, dpr, dt, still }: Frame) => { const c = colours(ctx.canvas), at = place(width, height), key = `${width}x${height}x${dpr}` if (key !== sizedFor) { sizedFor = key sheet = document.createElement('canvas') sheet.width = width * dpr sheet.height = height * dpr sheet.getContext('2d')!.setTransform(dpr, 0, 0, dpr, 0, 0) if (shown) shown = 0 } const g = sheet!.getContext('2d')!, target = still ? TOTAL : Math.min(TOTAL, shown + Math.max(1, dt * RATE)) g.strokeStyle = c.line g.lineWidth = 1 g.lineJoin = 'round' g.beginPath() for (let d = Math.max(1, Math.floor(shown)); d < target; d++) { const [ax, ay] = at(d - 1), [bx, by] = at(d); g.moveTo(ax, ay); g.lineTo(bx, by) } g.stroke() shown = target ctx.clearRect(0, 0, width, height) ctx.drawImage(sheet!, 0, 0, width, height) if (shown < TOTAL) { ctx.lineWidth = 1.5 ctx.lineJoin = 'round' ctx.strokeStyle = c.tail ctx.beginPath() const end = Math.floor(shown), from = Math.max(0, end - 40) for (let d = from; d < end; d++) { const [x, y] = at(d); d === from ? ctx.moveTo(x, y) : ctx.lineTo(x, y) } ctx.stroke() const [hx, hy] = at(end) ctx.fillStyle = c.head ctx.fillRect(hx - 2.5, hy - 2.5, 5, 5) } else if (running.value) running.value = false } return <Canvas draw={draw} animate={live && running.value} onTheme={() => { colours.reset(); sizedFor = '' }} label="A single Hilbert curve filling the card" />}Selvedge Plan
A floor plan with nothing to do: hairlines, seeded ratios, a few cells in colour. Click to re-split, and the walls spring to their new places.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { inks, rng, type ArtProps, type Meta } from './shared'import code from './selvedge-plan.tsx?raw'export const meta: Meta = { name: 'Selvedge Plan', description: 'A floor plan with nothing to do: hairlines, seeded ratios, a few cells in colour. Click to re-split, and the walls spring to their new places.', kind: 'generative', code, rest: true }const DEPTH = 5const colours = inks({ line: '--warp-color-text-subtle', soft: '--warp-color-accent-soft', mid: '--warp-color-accent-border', solid: '--warp-color-accent-solid', ground: '--warp-color-page' })// One fixed binary tree. Re-splitting only moves its ratios, so every wall has somewhere to spring from.const NODES = (1 << (DEPTH + 1)) - 1export default function SelvedgePlan(_: ArtProps) { const at = new Float32Array(NODES), to = new Float32Array(NODES), speed = new Float32Array(NODES), tone = new Uint8Array(NODES) let seed = 5 function roll() { const r = rng(seed++ * 7919) for (let i = 0; i < NODES; i++) { to[i] = 0.3 + r() * 0.4; tone[i] = r() < 0.09 ? 1 : r() < 0.04 ? 2 : 0 } tone[12 + (seed % 16)] = 3 } roll() at.set(to) const stroke = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) => { ctx.moveTo(x, y); ctx.lineTo(x + w, y + h) } function room(ctx: CanvasRenderingContext2D, c: ReturnType<typeof colours>, gain: number, n: number, x: number, y: number, w: number, h: number, depth: number, lines: number[]) { if (depth === DEPTH || (w < 34 && h < 34)) { const t = tone[n]! if (t === 3 && gain < 0.9) return if (t) { ctx.fillStyle = t === 1 ? c.soft : t === 2 ? c.mid : c.solid; ctx.fillRect(x, y, w, h) } return } const r = at[n]! if (w > h * 0.8) { const a = w * r lines.push(x + a, y, 0, h) room(ctx, c, gain, n * 2 + 1, x, y, a, h, depth + 1, lines) room(ctx, c, gain, n * 2 + 2, x + a, y, w - a, h, depth + 1, lines) } else { const a = h * r lines.push(x, y + a, w, 0) room(ctx, c, gain, n * 2 + 1, x, y, w, a, depth + 1, lines) room(ctx, c, gain, n * 2 + 2, x, y + a, w, h - a, depth + 1, lines) } } const draw = ({ ctx, width, height, tier, dt }: Frame) => { const c = colours(ctx.canvas), m = 18, lines: number[] = [] let moving = false for (let i = 0; i < NODES; i++) { const d = to[i]! - at[i]! speed[i] = (speed[i]! + d * dt * 90) * Math.exp(-dt * 11) at[i]! += speed[i]! * dt * 14 if (Math.abs(d) > 0.0008 || Math.abs(speed[i]!) > 0.0008) moving = true else at[i] = to[i]! } ctx.clearRect(0, 0, width, height) room(ctx, c, tier.gain, 0, m, m, width - m * 2, height - m * 2, 0, lines) ctx.beginPath() for (let i = 0; i < lines.length; i += 4) stroke(ctx, lines[i]!, lines[i + 1]!, lines[i + 2]!, lines[i + 3]!) ctx.rect(m, m, width - m * 2, height - m * 2) ctx.strokeStyle = c.line ctx.lineWidth = 1 ctx.stroke() return moving && tier.reach > 0 } return <Canvas draw={draw} pointer onPointerDown={roll} onTheme={colours.reset} label="Hairline rectangles split by seeded ratios" />}Ridgeline
Forty ridges of noise, drawn back to front so each one hides what stands behind it. The pointer lifts the land under it.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { fbm, inks, smooth, type ArtProps, type Meta } from './shared'import code from './ridgeline.tsx?raw'export const meta: Meta = { name: 'Ridgeline', description: 'Forty ridges of noise, drawn back to front so each one hides what stands behind it. The pointer lifts the land under it.', kind: 'generative', code, wide: true }const LINES = 40, POINTS = 150const colours = inks({ line: '--warp-color-text', ground: '--warp-color-page', accent: '--warp-color-accent-text' })export default function Ridgeline({ live = true }: ArtProps) { let px = 0, lift = 0 const ys = new Float32Array(POINTS + 1) const draw = ({ ctx, width, height, time, tier, pointer, dt }: Frame) => { const c = colours(ctx.canvas), inset = width * 0.11, span = width - inset * 2, gap = (height * 0.74) / LINES, t = 30 + time * tier.speed * 0.05, relief = height * 0.34 lift += ((pointer.in ? 1 : 0) - lift) * Math.min(1, dt * 5) if (pointer.in) px += (pointer.x - px) * Math.min(1, dt * 9) ctx.clearRect(0, 0, width, height) ctx.lineWidth = 1 ctx.lineJoin = 'round' for (let i = 0; i < LINES; i++) { const base = height * 0.2 + i * gap for (let n = 0; n <= POINTS; n++) { const u = n / POINTS, x = inset + u * span, centre = smooth(0, 0.42, u) * smooth(1, 0.58, u), peak = centre * centre const h = fbm(u * 5.5 + 3, i * 0.13 + t, 3), lifted = lift ? Math.exp(-(((x - px) / 70) ** 2)) * lift * smooth(0, 1, 1 - Math.abs(pointer.y - base) / 180) : 0 ys[n] = base - (0.03 + 1.1 * (h * h) * peak + 0.12 * peak * h) * relief * (1 + lifted * 0.9) } ctx.beginPath() ctx.moveTo(inset, ys[0]!) for (let n = 1; n <= POINTS; n++) ctx.lineTo(inset + (n / POINTS) * span, ys[n]!) ctx.lineTo(inset + span, base + 40) ctx.lineTo(inset, base + 40) ctx.closePath() ctx.fillStyle = c.ground ctx.fill() ctx.beginPath() ctx.moveTo(inset, ys[0]!) for (let n = 1; n <= POINTS; n++) ctx.lineTo(inset + (n / POINTS) * span, ys[n]!) ctx.strokeStyle = i === 27 ? c.accent : c.line ctx.stroke() } return lift > 0.01 && lift < 0.99 } return <Canvas draw={draw} animate={live} fps={30} pointer onTheme={colours.reset} label="Forty ridges of a hidden-line landscape" />}Draft
A real weaving drawdown. Threading along the top, tie-up in the corner, treadling down the side, and the cloth they make is whatever those three arrays say. This is how weavers wrote it down.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { palette } from '@weft-js/warp-core/dither'import { rng, type ArtProps, type Meta } from './shared'import code from './draft.tsx?raw'export const meta: Meta = { name: 'Draft', description: 'A real weaving drawdown. Threading along the top, tie-up in the corner, treadling down the side, and the cloth they make is whatever those three arrays say. This is how weavers wrote it down.', kind: 'generative', code, wide: true }const SHAFTS = 8, PITCH = 3, RULE = 2const SEED = 8/** A point twill threading: up and down the shafts, with a longer run every so often, which is what makes the diamonds. */function threading(n: number) { const out = new Uint8Array(n), r = rng(SEED) let at = 0, dir = 1, run = 3 + ((r() * 6) | 0) for (let i = 0; i < n; i++) { out[i] = at if (--run < 0) { dir = -dir; run = 3 + ((r() * 6) | 0) } at = (at + dir + SHAFTS) % SHAFTS } return out}// 2/2/1/3 twill on eight shafts: each treadle lifts a run of them.const TIE = Array.from({ length: SHAFTS }, (_, t) => Array.from({ length: SHAFTS }, (__, s) => { const k = (s - t + SHAFTS) % SHAFTS; return k < 2 || k === 3 ? 1 : 0 }))export default function Draft({ live = true }: ArtProps) { let image: ImageData | undefined, out = new Uint32Array(0), inksNow: Uint32Array | undefined, thread = new Uint8Array(0), cols = 0 const treadle = (y: number) => { const k = ((y % 52) + 52) % 52, up = k < 26 ? k : 51 - k; return (up + (y >> 6)) % SHAFTS } const draw = ({ ctx, width, height, time, tier, still }: Frame) => { if (!inksNow) inksNow = palette(ctx.canvas.parentElement!, ['--warp-color-page', '--warp-color-accent-text', '--warp-color-accent-soft', '--warp-color-border-strong', '--warp-color-text']) const [ground, warp, weft, rule, mark] = inksNow as unknown as number[] if (!image || image.width !== width || image.height !== height) { image = new ImageData(width, height) out = new Uint32Array(image.data.buffer) cols = Math.ceil(width / PITCH) thread = threading(cols) } out.fill(ground!) const margin = SHAFTS * PITCH + RULE * 2, right = width - margin, shift = Math.floor((still ? 24 : 24 + time * (2 + tier.speed * 6))) const span = Math.floor(right / PITCH) const set = (x: number, y: number, ink: number) => { for (let b = 0; b < PITCH - 1; b++) for (let a = 0; a < PITCH - 1; a++) out[(y + b) * width + x + a] = ink } // threading for (let x = 0; x < span; x++) set(x * PITCH, (SHAFTS - 1 - thread[x]!) * PITCH + RULE, mark!) // tie-up for (let t = 0; t < SHAFTS; t++) for (let s = 0; s < SHAFTS; s++) if (TIE[t]![s]) set(right + RULE + t * PITCH, (SHAFTS - 1 - s) * PITCH + RULE, mark!) const top = margin, rows = Math.floor((height - top) / PITCH) for (let y = 0; y < rows; y++) { const row = y + shift, tr = treadle(row), tie = TIE[tr]! // treadling, and the cloth: a warp thread on top wherever its shaft is tied to this row's treadle set(right + RULE + tr * PITCH, top + y * PITCH, mark!) for (let x = 0; x < span; x++) { const lifted = tie[thread[x]!] const ink = lifted ? warp! : weft!, px = x * PITCH, py = top + y * PITCH for (let b = 0; b < PITCH; b++) for (let a = 0; a < PITCH; a++) out[(py + b) * width + px + a] = ink } } for (let x = 0; x < width; x++) out[(margin - 1) * width + x] = rule! for (let y = 0; y < height; y++) out[y * width + right] = rule! ctx.putImageData(image, 0, 0) } return <Canvas draw={draw} animate={live} cell={2} fps={12} onTheme={() => { inksNow = undefined }} label="A weaving drawdown scrolling past, with its threading, tie-up and treadling" />}Shaders on whole cells
Per-pixel work, at a resolution small enough to stay cheap and big enough to read as ink.
Plasma Ink
Four sines added together, the oldest trick in the demoscene, printed in four inks through an ordered screen. The pointer shifts the phase.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { palette, quantize, tile } from '@weft-js/warp-core/dither'import { type ArtProps, type Meta } from './shared'import code from './plasma-ink.tsx?raw'export const meta: Meta = { name: 'Plasma Ink', description: 'Four sines added together, the oldest trick in the demoscene, printed in four inks through an ordered screen. The pointer shifts the phase.', kind: 'shader', code }const SIZE = 1024, WAVE = Float32Array.from({ length: SIZE }, (_, i) => Math.sin((i / SIZE) * Math.PI * 2)), sine = (v: number) => WAVE[(v * (SIZE / (Math.PI * 2))) & (SIZE - 1)]!const SCREEN = tile('bayer8')export default function PlasmaInk({ live = true }: ArtProps) { let tones = new Float32Array(0), out = new Uint32Array(0), image: ImageData | undefined, cells: Uint32Array | undefined, shift = 0 const draw = ({ ctx, width, height, time, tier, pointer, dt }: Frame) => { cells ??= palette(ctx.canvas.parentElement!, ['--warp-color-page', '--warp-color-accent-soft', '--warp-color-accent-border', '--warp-color-accent-solid', '--warp-color-text']) if (!image || image.width !== width || image.height !== height) { image = new ImageData(width, height) out = new Uint32Array(image.data.buffer) tones = new Float32Array(width * height) } if (pointer.in) shift += ((pointer.x / width - 0.5) * 6 - shift) * Math.min(1, dt * 3) const t = 11 + time * (0.2 + tier.speed * 0.5), k = 96 / height, cx = width * (0.5 + 0.3 * Math.sin(t * 0.3)), cy = height * (0.5 + 0.3 * Math.cos(t * 0.23)) for (let y = 0, i = 0; y < height; y++) { const v = y * k, b = sine(v * 0.13 - t * 0.7 + 200), dy = y - cy for (let x = 0; x < width; x++, i++) { const u = x * k, dx = x - cx const sum = sine(u * 0.11 + t + shift + 200) + b + sine((u + v) * 0.08 + t * 0.5 + 200) + sine(Math.sqrt(dx * dx + dy * dy) * k * 0.15 - t + 200) tones[i] = sum * 0.125 + 0.5 } } if (quantize(tones, width, height, SCREEN, cells, out)) ctx.putImageData(image, 0, 0) } return <Canvas draw={draw} animate={live} cell={3} pointer onTheme={() => { cells = undefined; image = undefined }} label="An interference plasma in four dithered inks" />}Bobbin SDF
A ray-marched ring of wound thread, 32 steps a pixel, shaded in nothing but dither inks. Drag to turn it. Turns by itself only at vivid.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { palette, quantize, tile } from '@weft-js/warp-core/dither'import { type ArtProps, type Meta } from './shared'import code from './bobbin-sdf.tsx?raw'export const meta: Meta = { name: 'Bobbin SDF', description: 'A ray-marched ring of wound thread, 32 steps a pixel, shaded in nothing but dither inks. Drag to turn it. Turns by itself only at vivid.', kind: 'shader', code }const { sin, cos, sqrt, abs, atan2 } = Mathconst R = 1, r = 0.46, BOUND = R + r + 0.02, STEPS = 32, EYE = 3.4const SCREEN = tile('bayer4')export default function BobbinSdf({ live = true }: ArtProps) { let tones = new Float32Array(0), out = new Uint32Array(0), image: ImageData | undefined, cells: Uint32Array | undefined let spin = 0.7, tilt = 1.02, dragging = false, lx = 0, ly = 0, parity = 0, painted = false function shade(px: number, py: number, dw: number, ca: number, sa: number, cb: number, sb: number) { // a ray through this pixel, in the ring's own frame const ox = 0, oy = 0, oz = -EYE, len = sqrt(px * px + py * py + dw * dw), dx0 = px / len, dy0 = py / len, dz0 = dw / len const dy1 = dy0 * ca - dz0 * sa, dz1 = dy0 * sa + dz0 * ca, oy1 = oy * ca - oz * sa, oz1 = oy * sa + oz * ca const dx = dx0 * cb + dz1 * sb, dz = -dx0 * sb + dz1 * cb, x0 = ox * cb + oz1 * sb, z0 = -ox * sb + oz1 * cb, y0 = oy1, dy = dy1 const b = x0 * dx + y0 * dy + z0 * dz, disc = b * b - (x0 * x0 + y0 * y0 + z0 * z0 - BOUND * BOUND) if (disc < 0) return 0 let t = -b - sqrt(disc) const far = -b + sqrt(disc) for (let i = 0; i < STEPS && t < far; i++) { const x = x0 + dx * t, y = y0 + dy * t, z = z0 + dz * t, q = sqrt(x * x + z * z) - R, d = sqrt(q * q + y * y) - r if (d < 0.003) { const major = atan2(z, x), minor = atan2(y, q), n = sqrt(x * x + z * z) || 1 const nx = (x / n) * q / r, ny = y / r, nz = (z / n) * q / r const light = abs(nx * 0.46 + ny * 0.79 + nz * -0.4), lit = nx * 0.46 + ny * 0.79 + nz * -0.4 const wind = sin(major * 62 + minor * 1.4), thread = 0.8 + 0.2 * wind, gloss = lit > 0 ? lit * lit * lit * 0.36 * (0.7 + 0.3 * wind) : 0 return Math.min(1, (0.1 + (lit > 0 ? lit : light * 0.08) * 0.68) * thread + gloss) } t += d } return 0 } const draw = ({ ctx, width, height, dt, tier, still, pointer }: Frame) => { cells ??= palette(ctx.canvas.parentElement!, ['--warp-color-page', '--warp-color-accent-soft', '--warp-color-accent-border', '--warp-color-accent-text', '--warp-color-text']) let fresh = false if (!image || image.width !== width || image.height !== height) { image = new ImageData(width, height) out = new Uint32Array(image.data.buffer) tones = new Float32Array(width * height) fresh = true } if (pointer.down) { if (dragging) { spin -= (pointer.x - lx) * 0.02; tilt += (pointer.y - ly) * 0.02 } dragging = true lx = pointer.x ly = pointer.y } else dragging = false const turning = !still && tier.speed >= 0.9 if (!turning && !dragging && painted && !fresh) return if (turning) spin += dt * 0.35 painted = true const ca = cos(tilt), sa = sin(tilt), cb = cos(spin), sb = sin(spin), scale = 2.5 / height, cx = width / 2, cy = height / 2 // half the rows each frame, in turn: the eye averages them and the budget holds const all = fresh || !turning for (let y = all ? 0 : parity; y < height; y += all ? 1 : 2) for (let x = 0, i = y * width; x < width; x++, i++) tones[i] = shade((x - cx) * scale, (y - cy) * scale, 2.6, ca, sa, cb, sb) parity ^= 1 quantize(tones, width, height, SCREEN, cells, out) ctx.putImageData(image, 0, 0) } return <Canvas draw={draw} animate={live} cell={3} fps={20} pointer onTheme={() => { cells = undefined; painted = false }} label="A torus of wound thread, ray-marched and dithered" />}Caustics
Light on the floor of a pool. The water folds it into bright threads, and they never quite repeat.
Code
import { DitherField } from '@weft-js/warp'import { type ArtProps, type Meta } from './shared'import code from './caustics.tsx?raw'export const meta: Meta = { name: 'Caustics', description: 'Light on the floor of a pool. The water folds it into bright threads, and they never quite repeat.', kind: 'shader', code, wide: true }const { abs, PI } = Mathconst SIZE = 4096, WAVE = Float32Array.from({ length: SIZE }, (_, i) => Math.sin((i / SIZE) * PI * 2)), PART = SIZE / (PI * 2)const sin = (v: number) => WAVE[(v * PART + SIZE * 512) & (SIZE - 1)]!// Where a warped wave crosses zero there is a thin bright line. Two sheets of those cross and knot into a net.function sheet(x: number, y: number, t: number) { const u = x * 5.2 + 1.5 * sin(y * 3.1 + t * 0.3), w = y * 5.6 + 1.5 * sin(x * 2.7 - t * 0.27), v = (sin(u + t * 0.2) + sin(w - t * 0.17)) * 0.5, k = 1 - abs(v), k2 = k * k, k4 = k2 * k2 return k4 * k4}function pool(x: number, y: number, t: number) { const a = sheet(x, y, t), b = sheet(y * 1.3 + 4, x * 1.3 - 2, t * 0.8 + 9) return Math.min(1, a * 0.72 + b * 0.5 + a * b * 1.2)}export default function Caustics({ live = true }: ArtProps) { return <DitherField fill kind={pool} pattern="blue" ramp={['none', '--warp-color-info-soft', '--warp-color-info-border', '--warp-color-info-text']} cell={3} gain={1.5} seed={6} speed={live ? 1 : 0} label="Caustic light on the floor of a pool" />}Bad Signal
A word on a failing tube. The rows tear in bursts and the rest barely holds. Good for a 404. Hover and it steadies.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { palette, quantize, rasterMask, tile, type Mask } from '@weft-js/warp-core/dither'import { smooth, noise, type ArtProps, type Meta } from './shared'import code from './bad-signal.tsx?raw'export const meta: Meta = { name: 'Bad Signal', description: 'A word on a failing tube. The rows tear in bursts and the rest barely holds. Good for a 404. Hover and it steadies.', kind: 'shader', code }const SCREEN = tile('bayer4')export default function BadSignal({ live = true }: ArtProps) { let tones = new Float32Array(0), out = new Uint32Array(0), image: ImageData | undefined, cells: Uint32Array | undefined, torn: number, mask: Mask | undefined, calm = 0 const draw = ({ ctx, width, height, time, dt, pointer, still }: Frame) => { const host = ctx.canvas.parentElement! if (!cells) { cells = palette(host, ['none', '--warp-color-border-strong', '--warp-color-text-subtle', '--warp-color-text']); torn = palette(host, ['--warp-color-critical-text'])[0]! } if (!image || image.width !== width || image.height !== height) { image = new ImageData(width, height) out = new Uint32Array(image.data.buffer) tones = new Float32Array(width * height) mask = rasterMask({ text: '404', weight: 600, fit: 0.72 }, width, height, getComputedStyle(host).fontFamily || 'sans-serif') } calm += ((pointer.in ? 1 : 0) - calm) * Math.min(1, dt * 6) const t = still ? 3.4 : time, wild = 1 - calm, shape = mask!.shape, halo = mask!.halo, burst = smooth(0.62, 0.8, noise(t * 1.4, 4)) * wild, roll = (t * 12) % height for (let y = 0, i = 0; y < height; y++) { const band = noise(y * 0.09, t * 3) - 0.5, jag = noise(y * 0.7, t * 13) - 0.5 const off = Math.round((band * 34 * burst + jag * (6 + 22 * burst)) * wild + Math.sin(y * 0.18 + t * 2) * 1.4 * wild), scan = y & 1 ? 0.8 : 1 const glow = 1 - Math.abs(((y - roll + height) % height) / height - 0.5) * 0.5 for (let x = 0; x < width; x++, i++) { const sx = x - off, at = sx < 0 || sx >= width ? -1 : y * width + sx const s = at < 0 ? 0 : shape[at]!, h = at < 0 ? 0 : halo[at]! tones[i] = (s * 0.96 + h * 0.14) * scan * glow } } quantize(tones, width, height, SCREEN, cells, out) if (burst > 0.2) for (let y = 0; y < height; y++) if (noise(y * 0.3, t * 9) > 0.66) for (let x = 0, i = y * width; x < width; x++, i++) if (out[i]) out[i] = torn ctx.putImageData(image, 0, 0) } return <Canvas draw={draw} animate={live} cell={3} fps={24} pointer onTheme={() => { cells = undefined; image = undefined }} label="The number 404 on a failing screen" />}Data as cloth
Figures you could read in a table, and can: each piece carries one for screen readers.
Commit Weave
A year of activity as a strip of cloth: fifty-two warps, seven wefts, and each thread stands as tall as that day was busy. Hover a day. The year here is a seeded sample.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { state } from '@weft-js/core'import { inks, mix, rng, type ArtProps, type Meta } from './shared'import code from './commit-weave.tsx?raw'export const meta: Meta = { name: 'Commit Weave', description: 'A year of activity as a strip of cloth: fifty-two warps, seven wefts, and each thread stands as tall as that day was busy. Hover a day. The year here is a seeded sample.', kind: 'data', code, wide: true, rest: true }const WEEKS = 52, DAYS = 7, NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']const colours = inks({ rule: '--warp-color-border', a: '--warp-color-accent-soft', b: '--warp-color-accent-border', c: '--warp-color-accent-text', d: '--warp-color-text', hot: '--warp-color-sweep' })/** Sample activity with a real year's shape: quiet weekends, a summer lull, three bursts before releases. */const year = (() => { const r = rng(52), out: number[] = [] for (let w = 0; w < WEEKS; w++) for (let d = 0; d < DAYS; d++) { const weekday = d === 0 || d === 6 ? 0.18 : 1, season = 0.55 + 0.45 * Math.cos(((w - 4) / WEEKS) * Math.PI * 2) ** 2, burst = [11, 27, 44].some(b => Math.abs(w - b) < 2) ? 2.3 : 1 out.push(Math.round(r() ** 1.7 * 14 * weekday * season * burst)) } return out})()const peak = Math.max(...year), total = year.reduce((a, b) => a + b, 0)export default function CommitWeave(_props: ArtProps) { const found = state('') const draw = ({ ctx, width, height, pointer }: Frame) => { const c = colours(ctx.canvas), mx = 18, my = 26, cw = (width - mx * 2) / WEEKS, ch = (height - my * 2) / DAYS ctx.clearRect(0, 0, width, height) // the wefts go first, so every warp lies over them ctx.fillStyle = c.rule for (let d = 0; d < DAYS; d++) ctx.fillRect(mx, my + (d + 0.5) * ch - 1, width - mx * 2, 2) const ramp = [c.a, c.b, c.c, c.d] const hx = pointer.in ? Math.floor((pointer.x - mx) / cw) : -1, hy = pointer.in ? Math.floor((pointer.y - my) / ch) : -1 for (let w = 0; w < WEEKS; w++) for (let d = 0; d < DAYS; d++) { const n = year[w * DAYS + d]!, k = n / peak, tall = mix(0.3, 1.16, Math.sqrt(k)) * ch, cx = mx + (w + 0.5) * cw, cy = my + (d + 0.5) * ch, thick = cw * 0.84 ctx.fillStyle = ramp[Math.min(3, Math.floor(Math.sqrt(k) * 4))]! ctx.fillRect(cx - thick / 2, cy - tall / 2, thick, tall) if ((w + d) % 2 && n < peak * 0.5) { ctx.fillStyle = c.rule; ctx.fillRect(cx - thick / 2, cy - 1, thick, 2) } if (w === hx && d === hy) { ctx.strokeStyle = c.hot; ctx.lineWidth = 2; ctx.strokeRect(cx - thick / 2 - 2, cy - tall / 2 - 2, thick + 4, tall + 4) } } const hit = hx >= 0 && hx < WEEKS && hy >= 0 && hy < DAYS found.value = hit ? `week ${hx + 1}, ${NAMES[hy]}: ${year[hx * DAYS + hy]} commits` : '' } return ( <> <Canvas draw={draw} pointer onTheme={colours.reset} label={`A year of activity as woven cloth, ${total} commits in all`} /> <p data-art="readout">{found.value}</p> <table data-art="table"> <caption>Commits by week, sample data</caption> <thead><tr><th scope="col">Week</th>{NAMES.map(n => <th key={n} scope="col">{n}</th>)}</tr></thead> <tbody>{Array.from({ length: WEEKS }, (_, w) => <tr key={w}><th scope="row">{w + 1}</th>{NAMES.map((n, d) => <td key={n}>{year[w * DAYS + d]}</td>)}</tr>)}</tbody> </table> </> )}Latency Rain
Each request falls as far as it took. The p50 and p95 sit as rules across the rain, and only an error gets a colour. Brush a window to read it. The requests here are a seeded sample.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { state } from '@weft-js/core'import { clamp, inks, rng, type ArtProps, type Meta } from './shared'import code from './latency-rain.tsx?raw'export const meta: Meta = { name: 'Latency Rain', description: 'Each request falls as far as it took. The p50 and p95 sit as rules across the rain, and only an error gets a colour. Brush a window to read it. The requests here are a seeded sample.', kind: 'data', code, wide: true }const COUNT = 900, MAX = 1000, LIFE = 9const colours = inks({ drop: '--warp-color-data-1', fine: '--warp-color-text-faint', err: '--warp-color-critical-text', rule: '--warp-color-text-subtle', band: '--warp-color-accent-soft', ground: '--warp-color-page' })// log-normal around 80 ms, with the long tail every real service has, and one bad patchconst r = rng(404)const requests = Array.from({ length: COUNT }, () => { const at = r(), normal = Math.sqrt(-2 * Math.log(r() || 0.001)) * Math.cos(6.283 * r()), bad = at > 0.62 && at < 0.7 const ms = Math.min(MAX, Math.exp(4.4 + normal * 0.7 + (bad ? 0.9 : 0))) return { at, ms, error: r() < (bad ? 0.14 : 0.004), phase: r() }}).sort((a, b) => a.at - b.at)const percentile = (list: number[], q: number) => { const s = [...list].sort((a, b) => a - b); return s[Math.floor((s.length - 1) * q)] ?? 0 }const whole = requests.map(q => q.ms), p50 = percentile(whole, 0.5), p95 = percentile(whole, 0.95), fails = requests.filter(q => q.error).lengthexport default function LatencyRain({ live = true }: ArtProps) { const found = state('') let from = -1, to = -1, wasDown = false const scale = (ms: number) => Math.log(1 + ms) / Math.log(1 + MAX) const draw = ({ ctx, width, height, time, still, pointer }: Frame) => { const c = colours(ctx.canvas), mx = 18, top = 14, bottom = height - 14, span = bottom - top, inner = width - mx * 2, clock = still ? 6.7 : time if (pointer.down) { const u = clamp((pointer.x - mx) / inner) if (!wasDown) from = u to = u const a = Math.min(from, to), b = Math.max(from, to), list = requests.filter(q => q.at >= a && q.at <= b).map(q => q.ms) if (list.length > 2) found.value = `${list.length} requests · p50 ${Math.round(percentile(list, 0.5))} ms · p95 ${Math.round(percentile(list, 0.95))} ms` } wasDown = pointer.down ctx.clearRect(0, 0, width, height) if (from >= 0) { ctx.fillStyle = c.band; ctx.fillRect(mx + Math.min(from, to) * inner, top, Math.abs(to - from) * inner, span) } for (let pass = 0; pass < 3; pass++) { ctx.fillStyle = pass === 2 ? c.err : pass ? c.drop : c.fine ctx.beginPath() for (let i = 0; i < COUNT; i++) { const q = requests[i]! if (+q.error * 2 !== pass && !(pass === 1 && !q.error && q.ms > p50) && !(pass === 0 && !q.error && q.ms <= p50)) continue if (pass === 2 && !q.error) continue if (pass < 2 && q.error) continue const age = ((clock / LIFE + q.phase) % 1), fall = Math.min(1, age * 3.2), reach = scale(q.ms) * span, tail = Math.min(reach, 12 + reach * 0.42) const y = top + reach * fall, x = mx + q.at * inner ctx.rect(x, y - tail * fall, pass === 2 ? 2 : 1, tail * fall) } ctx.fill() } ctx.fillStyle = c.rule ctx.font = '11px var(--warp-font-mono, monospace)' for (const [name, value] of [['p50', p50], ['p95', p95]] as const) { const y = Math.round(top + scale(value) * span) const text = `${name} ${Math.round(value)} ms` ctx.fillRect(mx, y, inner, 1) ctx.fillStyle = c.ground ctx.fillRect(mx, y - 17, ctx.measureText(text).width + 8, 16) ctx.fillStyle = c.rule ctx.fillText(text, mx + 4, y - 5) } } return ( <> <Canvas draw={draw} animate={live} fps={30} pointer style="touch-action:pan-y;cursor:crosshair" onTheme={colours.reset} label="Requests falling as streaks, length by latency" /> <p data-art="readout">{found.value}</p> <table data-art="table"> <caption>Latency summary, sample data</caption> <thead><tr><th scope="col">Measure</th><th scope="col">Value</th></tr></thead> <tbody> <tr><th scope="row">Requests</th><td>{COUNT}</td></tr> <tr><th scope="row">p50</th><td>{Math.round(p50)} ms</td></tr> <tr><th scope="row">p95</th><td>{Math.round(p95)} ms</td></tr> <tr><th scope="row">Errors</th><td>{fails}</td></tr> </tbody> </table> </> )}Bundle Tartan
warp's fourteen largest components, gzipped, set as a tartan: a stripe as wide as its bytes, crossed with itself. Hover to name a stripe.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { palette } from '@weft-js/warp-core/dither'import { state } from '@weft-js/core'import { type ArtProps, type Meta } from './shared'import code from './bundle-tartan.tsx?raw'export const meta: Meta = { name: 'Bundle Tartan', description: 'warp\'s fourteen largest components, gzipped, set as a tartan: a stripe as wide as its bytes, crossed with itself. Hover to name a stripe.', kind: 'data', code, wide: true, rest: true }// gzip -c of each source file, 2026-11-01const MODULES: [string, number][] = [['DataGrid', 9516], ['Table', 8949], ['VirtualList', 5702], ['Shell', 5557], ['CommandPalette', 5504], ['RichSelect', 5118], ['RichTextEditor', 4521], ['TimeField', 4283], ['DateField', 3821], ['MonthPicker', 3629], ['Calendar', 3461], ['ScrollGrid', 3254], ['Chart', 2943], ['RecurrenceField', 2924]]const total = MODULES.reduce((n, m) => n + m[1], 0), SETT_INKS = ['--warp-color-text', '--warp-color-accent-solid', '--warp-color-accent-soft', '--warp-color-caution-solid', '--warp-color-text-subtle', '--warp-color-page']/** A sett reflects at each end, as a real tartan's does. */const SETT = [...MODULES.keys(), ...[...MODULES.keys()].reverse()]export default function BundleTartan(_: ArtProps) { const found = state('') let inksNow: Uint32Array | undefined, map = new Int16Array(0), image: ImageData | undefined, out = new Uint32Array(0) function lay(cols: number) { map = new Int16Array(cols) let x = 0 for (let turn = 0; x < cols; turn++) for (const m of SETT) { const wide = Math.max(2, Math.round((MODULES[m]![1] / total) * 150)) for (let i = 0; i < wide && x < cols; i++, x++) map[x] = m } } const draw = ({ ctx, width, height, pointer }: Frame) => { inksNow ??= palette(ctx.canvas.parentElement!, SETT_INKS) if (!image || image.width !== width || image.height !== height) { image = new ImageData(width, height) out = new Uint32Array(image.data.buffer) lay(Math.max(width, height)) // 2/2 twill: warp and weft take turns on a diagonal, which is why tartan's crossings read as a third colour for (let y = 0, i = 0; y < height; y++) for (let x = 0; x < width; x++, i++) out[i] = inksNow[((x + y) & 3) < 2 ? map[x]! % SETT_INKS.length : map[y]! % SETT_INKS.length]! } ctx.putImageData(image, 0, 0) const over = pointer.in ? map[Math.min(width - 1, Math.floor(pointer.x))]! : -1 if (over >= 0) { found.value = `${MODULES[over]![0]}: ${(MODULES[over]![1] / 1000).toFixed(1)} kB` ctx.fillStyle = 'rgb(255 255 255 / 0.55)' for (let x = 0; x < width; x++) if (map[x] !== over) ctx.fillRect(x, 0, 1, height) } else found.value = '' } return ( <> <Canvas draw={draw} cell={2} pointer onTheme={() => { inksNow = undefined; image = undefined }} label={`A tartan of warp's fourteen largest components, ${(total / 1000).toFixed(0)} kB gzipped`} /> <p data-art="readout">{found.value}</p> <table data-art="table"> <caption>Gzipped size of warp's largest components</caption> <thead><tr><th scope="col">Component</th><th scope="col">Bytes</th></tr></thead> <tbody>{MODULES.map(([name, bytes]) => <tr key={name}><th scope="row">{name}</th><td>{bytes}</td></tr>)}</tbody> </table> </> )}Token Spectrum
Every colour warp has, plotted by lightness and chroma, live. Turn the dial, flip the theme or move the seed, and watch the whole system lean together.
Code
import { Canvas, Slider, type CanvasFrame as Frame } from '@weft-js/warp'import { palette } from '@weft-js/warp-core/dither'import { state } from '@weft-js/core'import { inks, type ArtProps, type Meta } from './shared'import code from './token-spectrum.tsx?raw'export const meta: Meta = { name: 'Token Spectrum', description: 'Every colour warp has, plotted by lightness and chroma, live. Turn the dial, flip the theme or move the seed, and watch the whole system lean together.', kind: 'data', code, wide: true, rest: true }const FAMILIES = ['accent', 'positive', 'caution', 'critical', 'info', 'highlight', 'neutral'], STEPS = ['soft', 'border', 'text', 'solid', 'solid-hover']const NAMES = [...FAMILIES.flatMap(f => STEPS.map(s => `${f}-${s}`)), 'page', 'surface', 'surface-sunken', 'surface-raised', 'surface-hover', 'text', 'text-subtle', 'text-faint', 'border', 'border-strong', 'border-control', 'focus', 'sweep', 'data-1', 'data-2', 'data-3', 'data-4', 'data-5', 'data-6', 'data-7', 'data-8']const TOKENS = NAMES.map(n => `--warp-color-${n}`), COUNT = NAMES.length, FAMILY = STEPS.lengthconst colours = inks({ rule: '--warp-color-border', axis: '--warp-color-text-faint', label: '--warp-color-text-subtle', hot: '--warp-color-text' })const MAX_C = 0.3const linear = (v: number) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }/** Packed RGBA to OKLCH lightness and chroma: the plane warp's tokens are designed on. */function oklch(rgba: number) { const r = linear(rgba & 255), g = linear((rgba >> 8) & 255), b = linear((rgba >> 16) & 255) const l = Math.cbrt(0.4122 * r + 0.5363 * g + 0.0514 * b), m = Math.cbrt(0.2119 * r + 0.6807 * g + 0.1074 * b), s = Math.cbrt(0.0883 * r + 0.2817 * g + 0.6300 * b) const L = 0.2104 * l + 0.7936 * m - 0.0040 * s, A = 1.9779 * l - 2.4286 * m + 0.4506 * s, B = 0.0259 * l + 0.7828 * m - 0.8087 * s return [L, Math.hypot(A, B)] as const}const css = (v: number) => `rgb(${v & 255} ${(v >> 8) & 255} ${(v >> 16) & 255})`export default function TokenSpectrum(_: ArtProps) { const found = state(''), hue = state(152), moving = state(false) const ls = new Float32Array(COUNT), cs = new Float32Array(COUNT), tl = new Float32Array(COUNT), tc = new Float32Array(COUNT) let fills: string[] = [], stale = true, first = true const reread = (el: Element) => { const packed = palette(el, TOKENS) fills = [] for (let i = 0; i < COUNT; i++) { const [l, c] = oklch(packed[i]!); tl[i] = l; tc[i] = c; fills.push(css(packed[i]!)) } if (first) { ls.set(tl); cs.set(tc); first = false } stale = false } const draw = ({ ctx, width, height, dt, pointer }: Frame) => { if (stale) reread(ctx.canvas.parentElement!) const c = colours(ctx.canvas), mx = 42, my = 22, w = width - mx * 2, h = height - my * 2 - 12, across = (chroma: number) => mx + (chroma / MAX_C) * w, up = (light: number) => my + (1 - light) * h let settling = false const k = Math.min(1, (dt || 0.016) * 7) for (let i = 0; i < COUNT; i++) { ls[i]! += (tl[i]! - ls[i]!) * k cs[i]! += (tc[i]! - cs[i]!) * k if (Math.abs(tl[i]! - ls[i]!) + Math.abs(tc[i]! - cs[i]!) > 0.0015) settling = true } ctx.clearRect(0, 0, width, height) ctx.font = '11px var(--warp-font-mono, monospace)' ctx.fillStyle = c.rule for (let v = 0; v <= 1.001; v += 0.25) ctx.fillRect(mx, Math.round(up(v)), w, 1) for (let v = 0; v <= MAX_C; v += 0.1) ctx.fillRect(Math.round(across(v)), my, 1, h) ctx.fillStyle = c.axis ctx.fillText('L', 16, my + 10) ctx.fillText('chroma →', mx + w - 54, my + h + 16) // a family's steps are joined, so a ramp reads as a stroke across the plane ctx.lineWidth = 1 for (let f = 0; f < FAMILIES.length; f++) { const base = f * FAMILY ctx.strokeStyle = fills[base + 3]! ctx.beginPath() for (let s = 0; s < 4; s++) { const i = base + s; s ? ctx.lineTo(across(cs[i]!), up(ls[i]!)) : ctx.moveTo(across(cs[i]!), up(ls[i]!)) } ctx.stroke() } let near = -1, best = 196 for (let i = 0; i < COUNT; i++) { const x = across(cs[i]!), y = up(ls[i]!) ctx.fillStyle = fills[i]! ctx.beginPath() ctx.arc(x, y, 5, 0, 6.2832) ctx.fill() ctx.strokeStyle = c.rule ctx.stroke() if (pointer.in) { const d = (x - pointer.x) ** 2 + (y - pointer.y) ** 2; if (d < best) { best = d; near = i } } } if (near >= 0) { ctx.strokeStyle = c.hot; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(across(cs[near]!), up(ls[near]!), 8, 0, 6.2832); ctx.stroke() } found.value = near >= 0 ? `${NAMES[near]} L ${ls[near]!.toFixed(3)} C ${cs[near]!.toFixed(3)}` : '' if (!settling && moving.value) moving.value = false return settling } const turn = (v: number) => { hue.value = v; stale = true; moving.value = true } return ( <div data-art="pierce" data-warp="" style={`--warp-seed-accent-h:${hue.value}`}> <Canvas draw={draw} animate={moving.value} pointer onTheme={() => { colours.reset(); stale = true }} label="Every warp colour token on the lightness and chroma plane" /> <div data-art="controls" data-art-end=""><div data-art-seed=""><Slider label="Seed hue" value={hue.value} min={0} max={360} onChange={turn} /></div></div> <p data-art="readout">{found.value}</p> </div> )}Socket Tide
A day of open connections around a clock, rising with one timezone and falling with the next. Point at an hour to read it. The traffic here is a seeded sample: the site has no public socket to read yet.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { state } from '@weft-js/core'import { inks, rng, type ArtProps, type Meta } from './shared'import code from './socket-tide.tsx?raw'export const meta: Meta = { name: 'Socket Tide', description: 'A day of open connections around a clock, rising with one timezone and falling with the next. Point at an hour to read it. The traffic here is a seeded sample: the site has no public socket to read yet.', kind: 'data', code }const SLOTS = 288, TAU = Math.PI * 2const colours = inks({ rule: '--warp-color-border', low: '--warp-color-accent-border', high: '--warp-color-accent-text', hand: '--warp-color-sweep', label: '--warp-color-text-faint' })const day = (() => { const r = rng(24), out = new Float32Array(SLOTS) let noise = 0 for (let i = 0; i < SLOTS; i++) { const h = (i / SLOTS) * 24, gauss = (at: number, wide: number) => Math.exp(-(((h - at + 36) % 24 - 12) ** 2) / wide) noise = noise * 0.8 + (r() - 0.5) * 0.09 out[i] = 0.24 + 0.72 * gauss(14, 10) + 0.5 * gauss(21, 7) + 0.26 * gauss(7, 5) + noise } return out})()const scale = 1800, count = (i: number) => Math.round(day[i]! * scale)export default function SocketTide({ live = true }: ArtProps) { const found = state('') const draw = ({ ctx, width, height, time, still, pointer }: Frame) => { const c = colours(ctx.canvas), cx = width / 2, cy = height / 2, outer = Math.min(width, height) * 0.47, inner = outer * 0.36, span = outer - inner, t = still ? 2 : time const dx = pointer.x - cx, dy = pointer.y - cy, at = pointer.in && dx * dx + dy * dy > 400 ? Math.floor((((Math.atan2(dy, dx) + Math.PI / 2 + TAU) % TAU) / TAU) * SLOTS) : -1 ctx.clearRect(0, 0, width, height) ctx.lineWidth = 1 ctx.strokeStyle = c.rule ctx.beginPath() for (const k of [0, 0.5, 1]) { ctx.moveTo(cx + inner + span * k, cy); ctx.arc(cx, cy, inner + span * k, 0, TAU) } ctx.stroke() for (let pass = 0; pass < 2; pass++) { ctx.strokeStyle = pass ? c.high : c.low ctx.lineWidth = pass ? 1.4 : 1 ctx.beginPath() for (let i = pass ? 0 : 1; i < SLOTS; i += 2) { const a = (i / SLOTS) * TAU - Math.PI / 2, swell = Math.sin(a * 3 + t * 0.7) * 0.012 + Math.sin(a * 11 - t * 1.1) * 0.006, r = inner + span * Math.min(1, day[i]! + swell), cos = Math.cos(a), sin = Math.sin(a) ctx.moveTo(cx + cos * inner, cy + sin * inner) ctx.lineTo(cx + cos * r, cy + sin * r) } ctx.stroke() } ctx.fillStyle = c.label ctx.font = '11px var(--warp-font-mono, monospace)' ctx.textAlign = 'center' for (let h = 0; h < 24; h += 6) { const a = (h / 24) * TAU - Math.PI / 2; ctx.fillText(String(h).padStart(2, '0'), cx + Math.cos(a) * inner * 0.74, cy + Math.sin(a) * inner * 0.74 + 4) } ctx.textAlign = 'start' const hand = at >= 0 ? at : (0.4 * SLOTS + t * 0.5) % SLOTS, a = (hand / SLOTS) * TAU - Math.PI / 2 ctx.strokeStyle = c.hand ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(cx + Math.cos(a) * inner, cy + Math.sin(a) * inner) ctx.lineTo(cx + Math.cos(a) * (outer + 2), cy + Math.sin(a) * (outer + 2)) ctx.stroke() found.value = at >= 0 ? `${String(Math.floor(at / 12)).padStart(2, '0')}:${String((at % 12) * 5).padStart(2, '0')} ${count(at).toLocaleString('en')} open` : '' } return ( <> <Canvas draw={draw} animate={live} fps={30} pointer onTheme={colours.reset} label="A day of socket connections around a clock" /> <p data-art="readout">{found.value}</p> <table data-art="table"> <caption>Open connections by hour, sample data</caption> <thead><tr><th scope="col">Hour</th><th scope="col">Connections</th></tr></thead> <tbody>{Array.from({ length: 24 }, (_, h) => <tr key={h}><th scope="row">{h}:00</th><td>{count(h * 12)}</td></tr>)}</tbody> </table> </> )}For your hand
Nothing happens until you touch them, and they cost nothing until you do.
Pluck
Sixty strings, each a one-dimensional wave equation. Drag across them. When they settle the canvas stops, so at rest it costs nothing.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { inks, type ArtProps, type Meta } from './shared'import code from './pluck.tsx?raw'export const meta: Meta = { name: 'Pluck', description: 'Sixty strings, each a one-dimensional wave equation. Drag across them. When they settle the canvas stops, so at rest it costs nothing.', kind: 'pointer', code, wide: true, rest: true }const STRINGS = 60, NODES = 44, WAVE = 0.3, DAMP = 0.996const colours = inks({ line: '--warp-color-border-control', hot: '--warp-color-accent-text', spark: '--warp-color-sweep' })export default function Pluck(_: ArtProps) { const u = new Float32Array(STRINGS * NODES), v = new Float32Array(STRINGS * NODES) let lx = -1, due = 0, ready = false /** Bends string `s` into a soft triangle peaking at `k` along it, the shape a finger leaves. */ function bend(s: number, k: number, size: number) { const at = s * NODES for (let n = 1; n < NODES - 1; n++) { const f = n / (NODES - 1); u[at + n] = size * (f < k ? f / k : (1 - f) / (1 - k)) } } function step() { let peak = 0 for (let s = 0; s < STRINGS; s++) { const at = s * NODES for (let n = 1; n < NODES - 1; n++) { const i = at + n; v[i] = (v[i]! + (u[i - 1]! + u[i + 1]! - 2 * u[i]!) * WAVE) * DAMP } } for (let i = 0; i < u.length; i++) { u[i]! += v[i]!; const a = Math.abs(u[i]!) + Math.abs(v[i]!); if (a > peak) peak = a } return peak } // A still is a chord caught mid-ring: the first run of it, frozen. function chord() { for (let s = 6; s < STRINGS - 6; s += 1) bend(s, 0.3 + 0.4 * (s / STRINGS), 26 * Math.sin((s / STRINGS) * Math.PI) ** 3 * Math.sin(s * 0.35) ** 2) for (let i = 0; i < 9; i++) step() ready = true } const draw = ({ ctx, width, height, dt, tier, pointer }: Frame) => { const c = colours(ctx.canvas), gap = width / (STRINGS + 1), my = 14, cell = (height - my * 2) / (NODES - 1) if (!ready) chord() if (tier.reach > 0 && pointer.in) { if (lx >= 0 && pointer.x !== lx) { const a = Math.floor(Math.min(lx, pointer.x) / gap), b = Math.floor(Math.max(lx, pointer.x) / gap), dir = pointer.x > lx ? 1 : -1 for (let s = Math.max(0, a); s <= Math.min(STRINGS - 1, b); s++) if (s + 1 > Math.min(lx, pointer.x) / gap && s + 1 <= Math.max(lx, pointer.x) / gap) bend(s, Math.max(0.1, Math.min(0.9, (pointer.y - my) / (height - my * 2))), dir * (pointer.down ? 22 : 13)) } lx = pointer.x } else lx = -1 let peak = 0 if (tier.reach > 0) { due = Math.min(due + dt, 0.05) for (; due > 0.004; due -= 0.004) peak = step() } ctx.clearRect(0, 0, width, height) for (let pass = 0; pass < 2; pass++) { ctx.beginPath() for (let s = 0; s < STRINGS; s++) { const at = s * NODES, x = (s + 1) * gap let energy = 0 for (let n = 1; n < NODES - 1; n += 4) energy += Math.abs(u[at + n]!) if ((energy > 6) !== !!pass) continue ctx.moveTo(x + u[at]!, my) for (let n = 1; n < NODES; n++) ctx.lineTo(x + u[at + n]!, my + n * cell) } ctx.strokeStyle = pass ? c.hot : c.line ctx.lineWidth = pass ? 1.25 : 1 ctx.stroke() } return tier.reach > 0 && peak > 0.05 } return <Canvas draw={draw} pointer style="touch-action:pan-y" onTheme={colours.reset} label="Sixty strings that ring when dragged across" />}Filings
Iron filings around two poles. Bring the pointer in as a third, and click to flip it.
Code
import { Canvas, type CanvasFrame as Frame } from '@weft-js/warp'import { inks, type ArtProps, type Meta } from './shared'import code from './filings.tsx?raw'export const meta: Meta = { name: 'Filings', description: 'Iron filings around two poles. Bring the pointer in as a third, and click to flip it.', kind: 'pointer', code, rest: true }const PITCH = 11, LENGTH = 9const colours = inks({ near: '--warp-color-text', mid: '--warp-color-text-subtle', far: '--warp-color-text-faint' })export default function Filings(_: ArtProps) { let cols = 0, rows = 0, angle = new Float32Array(0), polarity = 1 const draw = ({ ctx, width, height, dt, pointer, tier }: Frame) => { const c = colours(ctx.canvas), nc = Math.ceil(width / PITCH), nr = Math.ceil(height / PITCH), fresh = nc !== cols || nr !== rows if (fresh) { cols = nc; rows = nr; angle = new Float32Array(cols * rows) } const ax = width * 0.33, bx = width * 0.67, mid = height / 2, poles = [[ax, mid, 1], [bx, mid, -1], ...(pointer.in ? [[pointer.x, pointer.y, 2.2 * polarity]] : [])] as number[][] const k = fresh || !dt ? 1 : Math.min(1, dt * (tier.reach > 0 ? 12 : 60)), buckets: number[][] = [[], [], []] let moving = false for (let y = 0, i = 0; y < rows; y++) for (let x = 0; x < cols; x++, i++) { const px = (x + 0.5) * PITCH, py = (y + 0.5) * PITCH let fx = 0, fy = 0 for (const [qx, qy, q] of poles) { const dx = px - qx!, dy = py - qy!, d = dx * dx + dy * dy + 80, s = (q! * 30000) / d; fx += (dx / Math.sqrt(d)) * s; fy += (dy / Math.sqrt(d)) * s } const want = Math.atan2(fy, fx), turn = ((want - angle[i]! + Math.PI * 3) % (Math.PI * 2)) - Math.PI if (Math.abs(turn) > 0.01) moving = true const now = (angle[i]! += turn * k), power = Math.hypot(fx, fy), tick = LENGTH * Math.min(1, 0.5 + power * 0.1), cos = (Math.cos(now) * tick) / 2, sin = (Math.sin(now) * tick) / 2 buckets[power > 7 ? 0 : power > 1.6 ? 1 : 2]!.push(px - cos, py - sin, px + cos, py + sin) } ctx.clearRect(0, 0, width, height) ctx.lineCap = 'round' ;[c.near, c.mid, c.far].forEach((ink, n) => { const list = buckets[n]! ctx.strokeStyle = ink ctx.lineWidth = n ? 1 : 1.3 ctx.beginPath() for (let i = 0; i < list.length; i += 4) { ctx.moveTo(list[i]!, list[i + 1]!); ctx.lineTo(list[i + 2]!, list[i + 3]!) } ctx.stroke() }) return moving } return <Canvas draw={draw} pointer onPointerDown={() => { polarity = -polarity }} onTheme={colours.reset} label="Iron filings lined up between two magnetic poles" />}Sand Table
A raked garden. Draw through it and the sand piles up along your groove, then slowly finds its way back.
Code
import { DitherField } from '@weft-js/warp'import { state } from '@weft-js/core'import { clamp, type ArtProps, type Meta } from './shared'import code from './sand-table.tsx?raw'export const meta: Meta = { name: 'Sand Table', description: 'A raked garden. Draw through it and the sand piles up along your groove, then slowly finds its way back.', kind: 'pointer', code, rest: true }const W = 170, H = 106, STONES = [[0.3, 0.42, 0.11], [0.72, 0.62, 0.075], [0.56, 0.2, 0.04]] as constexport default function SandTable(_: ArtProps) { const rake = new Float32Array(W * H), sand = new Float32Array(W * H), rock = new Uint8Array(W * H), shade = new Float32Array(W * H) const busy = state(false) let aspect = 1.6, stamp = NaN, built = false, lx = -1, ly = -1, restless = 0 function build() { for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) { const u = (x / W) * 1.6, v = y / H let near = 9, stone = -1 STONES.forEach(([sx, sy, r], n) => { const d = Math.hypot(u - sx * 1.6, v - sy) - r; if (d < near) { near = d; stone = n } }) // rings round each stone give way to straight rows, as a rake does const rows = Math.sin(v * 2 * Math.PI * 11), rings = Math.sin(near * 2 * Math.PI * 11 + stone), k = clamp((near - 0.2) / 0.09) rake[i] = sand[i] = (rings * (1 - k) + rows * k) * 0.5 rock[i] = near < 0 ? 1 : 0 } relight() built = true } function relight() { for (let y = 1; y < H - 1; y++) for (let x = 1, i = y * W + 1; x < W - 1; x++, i++) shade[i] = rock[i] ? 1 : clamp(0.44 + (sand[i - 1]! - sand[i + 1]! + sand[i - W]! - sand[i + W]!) * 0.26) } function settle() { let moved = 0 for (let y = 1; y < H - 1; y++) for (let x = 1, i = y * W + 1; x < W - 1; x++, i++) { const d = rake[i]! - sand[i]! if (Math.abs(d) < 0.004) continue moved++ sand[i]! += d * 0.012 + (sand[i - 1]! + sand[i + 1]! + sand[i - W]! + sand[i + W]! - 4 * sand[i]!) * 0.05 } return moved } const garden = (x: number, y: number, t: number) => { if (!built) build() if (x > aspect) aspect = x if (t !== stamp) { stamp = t if (settle() < 40 && --restless < 0) busy.value = false relight() } return shade[(Math.min(H - 1, (y * H) | 0)) * W + Math.min(W - 1, ((x / aspect) * W) | 0)]! } function plough(px: number, py: number) { for (let dy = -5; dy <= 5; dy++) for (let dx = -5; dx <= 5; dx++) { const X = px + dx, Y = py + dy, d = Math.hypot(dx, dy) if (X < 1 || Y < 1 || X >= W - 1 || Y >= H - 1 || d > 5) continue const i = Y * W + X if (!rock[i]) sand[i] = d < 2.6 ? -1 : d < 4.4 ? Math.max(sand[i]!, 0.9) : sand[i]! } } const move = (e: PointerEvent) => { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(), x = ((e.clientX - r.left) / r.width * W) | 0, y = ((e.clientY - r.top) / r.height * H) | 0, steps = lx < 0 ? 1 : Math.max(1, Math.hypot(x - lx, y - ly) | 0) for (let n = 1; n <= steps; n++) plough(lx < 0 ? x : Math.round(lx + ((x - lx) * n) / steps), lx < 0 ? y : Math.round(ly + ((y - ly) * n) / steps)) lx = x ly = y restless = 30 busy.value = true } return <DitherField fill kind={garden} ramp="neutral" pattern="blue" cell={3} spread={0.9} gain={1.25} seed={2} speed={busy.value ? 1 : 0} onPointerMove={move} onPointerLeave={() => { lx = -1 }} style="touch-action:none" label="A raked sand garden around three stones" />}