New
Flock SearchParticles
A shallow-water surface solved on a grid: drips fall on a clock, their rings interfere, and the wake reflects off the walls of the tank instead of fading out. Click to drop your own.
"use client"
import { useCanvasScene, useReducedMotion } from "../hooks/use-canvas-scene"
/**
* Ripple tank — two-source wave interference.
*
* A scalar height field is summed over two point sources at a fixed timestep:
*
* h(p, t) = Σ_s A/√(r_s + r0) · sin(k·r_s − ω·t) k = 2π/λ, ω = 2π·f
*
* The per-cell distances r_s are fixed, so `setup` precomputes each source's
* spatial phase (k·r_s) and amplitude envelope once; every frame then costs one
* sin per source per cell. Time advances by a constant dt (no keyframes, no
* easing) — the motion is the equation, integrated.
*
* The field is evaluated on a coarse buffer and bilinearly upscaled to the
* stage, so a 60fps paint stays cheap regardless of canvas size. Under
* prefers-reduced-motion a single frame is drawn: the analytic superposition
* amplitude |A₁e^{ikr₁} + A₂e^{ikr₂}|, i.e. the standing hyperbolic fringes.
*/
export type RippleTankProps = { compact?: boolean; className?: string }
type Field = {
cols: number
rows: number
buffer: HTMLCanvasElement
bctx: CanvasRenderingContext2D
image: ImageData
/** k·r_s per cell, one array per source. */
phase: Float32Array[]
/** A/√(r_s + r0) per cell, one array per source. */
amp: Float32Array[]
/** Soft additive source glow per cell (summed over sources). */
glow: Float32Array
/** Static superposition amplitude per cell — the reduced-motion frame. */
still: Float32Array
/** ω·dt: the fixed phase advance per frame. */
step: number
/** 1 / peak instantaneous |h|, for the animated frame. */
invNorm: number
/** 1 / peak superposition amplitude, for the still frame. */
invStill: number
}
/**
* Precompute everything that does not move: the source geometry, and for every
* cell of the coarse buffer the spatial phase k·r and amplitude A/√(r+r0) of
* each source, plus the analytic standing-wave amplitude used for the reduced
* frame. Re-run by the hook on every resize, so the grid is always sized to the
* stage and never resized in place.
*/
function buildField(width: number, height: number, compact: boolean): Field {
// Coarse cells: a wave is ~44px across, so ~6px cells resolve every crest
// with margin while keeping the buffer small enough to sum at 60fps. Cap the
// total cell count so a large stage scales the cell up instead of the cost.
const target = compact ? 7 : 6
const maxCells = compact ? 9000 : 22000
let cell = target
let cols = Math.max(2, Math.ceil(width / cell))
let rows = Math.max(2, Math.ceil(height / cell))
while (cols * rows > maxCells) {
cell += 1
cols = Math.max(2, Math.ceil(width / cell))
rows = Math.max(2, Math.ceil(height / cell))
}
// Physical constants, in CSS pixels. λ sets fringe spacing; the source
// separation is a few wavelengths so several hyperbolic nodes are in frame.
const minSide = Math.min(width, height)
const wavelength = Math.max(26, Math.min(52, minSide * 0.11))
const k = (2 * Math.PI) / wavelength
const r0 = wavelength * 0.5 // softening: caps amplitude at the singularity
const envelope = Math.max(width, height) * 0.62 // gentle radial fade
const sep = Math.min(width * 0.42, wavelength * 3.6)
const cx = width / 2
const cy = height / 2
const sources = [
{ x: cx - sep / 2, y: cy },
{ x: cx + sep / 2, y: cy },
]
const A = compact ? 0.86 : 1
const count = cols * rows
const phase = sources.map(() => new Float32Array(count))
const amp = sources.map(() => new Float32Array(count))
const glow = new Float32Array(count)
const still = new Float32Array(count)
let peakAmp = 0 // Σ|amp| — the largest instantaneous |h| any cell can reach
let peakStill = 0
for (let gy = 0; gy < rows; gy++) {
// Sample at cell centres, mapped back into stage pixels.
const py = ((gy + 0.5) / rows) * height
for (let gx = 0; gx < cols; gx++) {
const px = ((gx + 0.5) / cols) * width
const i = gy * cols + gx
let ampSum = 0
let reCos = 0
let reSin = 0
let glowSum = 0
for (let s = 0; s < sources.length; s++) {
const dx = px - sources[s].x
const dy = py - sources[s].y
const r = Math.sqrt(dx * dx + dy * dy)
const a = (A / Math.sqrt(r + r0)) * Math.exp(-r / envelope)
const ph = k * r
phase[s][i] = ph
amp[s][i] = a
ampSum += a
// Phasor sum → standing amplitude |Σ a·e^{ikr}| for the still frame.
reCos += a * Math.cos(ph)
reSin += a * Math.sin(ph)
glowSum += Math.exp(-r / (wavelength * 1.5))
}
glow[i] = glowSum
const st = Math.sqrt(reCos * reCos + reSin * reSin)
still[i] = st
if (ampSum > peakAmp) peakAmp = ampSum
if (st > peakStill) peakStill = st
}
}
const buffer = document.createElement("canvas")
buffer.width = cols
buffer.height = rows
const bctx = buffer.getContext("2d")
if (!bctx) throw new Error("ripple-tank: 2D context unavailable")
const image = bctx.createImageData(cols, rows)
// ω·dt as one number. A period of ~52 frames reads as calm, deliberate water.
const step = (2 * Math.PI) / 52
return {
cols,
rows,
buffer,
bctx,
image,
phase,
amp,
glow,
still,
step,
invNorm: peakAmp > 0 ? 1 / peakAmp : 1,
invStill: peakStill > 0 ? 1 / peakStill : 1,
}
}
export function RippleTank({ compact = false, className }: RippleTankProps) {
const reduced = useReducedMotion()
const { stageRef, canvasRef } = useCanvasScene<Field>({
setup: ({ width, height }) => buildField(width, height, compact),
draw: ({ context, width, height, state, frame }) =>
drawField(context, width, height, state, frame, reduced, compact),
})
return (
<div
ref={stageRef}
className={`relative block size-full overflow-hidden ${className ?? ""}`.trim()}
style={{ touchAction: "pan-y", background: "rgb(8, 11, 28)" }}
role="img"
aria-label="Two-source wave interference on a ripple tank, cyan crests fanning into hyperbolic fringes"
>
<canvas ref={canvasRef} className="block size-full" />
</div>
)
}
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)))
return t * t * (3 - 2 * t)
}
/**
* Paint one frame. `frame·step` is the integrated time t; the field is the sum
* of each source's precomputed envelope times sin(phase − t). The value is
* mapped through a deep-indigo → cyan → white ramp, written to the coarse
* buffer, then upscaled with bilinear smoothing to fill the stage.
*/
function drawField(
context: CanvasRenderingContext2D,
width: number,
height: number,
field: Field,
frame: number,
reduced: boolean,
compact: boolean,
) {
const { cols, rows, phase, amp, glow, still, image, step, invNorm, invStill } = field
const data = image.data
const t = frame * step
// Rest indigo, and the deltas a crest/trough push it by. Kept subtle so the
// tank reads as lit water rather than neon.
const baseR = 11
const baseG = 17
const baseB = 40
const crestR = 122
const crestG = 226
const crestB = 255
const contrast = compact ? 1.08 : 1.22
const glowGain = compact ? 0.5 : 0.85
const src0Phase = phase[0]
const src1Phase = phase[1]
const src0Amp = amp[0]
const src1Amp = amp[1]
for (let i = 0; i < cols * rows; i++) {
let up: number
let down: number
if (reduced) {
// Standing superposition amplitude: the time-invariant fringe pattern.
up = Math.min(1, still[i] * invStill * contrast)
down = 0
} else {
const h =
(src0Amp[i] * Math.sin(src0Phase[i] - t) + src1Amp[i] * Math.sin(src1Phase[i] - t)) *
invNorm *
contrast
up = h > 0 ? Math.min(1, h) : 0
down = h < 0 ? Math.min(1, -h) : 0
}
// Crest side: lerp indigo → cyan, then lift the peaks toward white.
const c = Math.pow(up, 0.82)
let r = baseR + c * (crestR - baseR)
let g = baseG + c * (crestG - baseG)
let b = baseB + c * (crestB - baseB)
const hl = smoothstep(0.62, 1, c)
r += hl * (255 - r) * 0.85
g += hl * (255 - g) * 0.8
b += hl * (255 - b) * 0.6
// Trough side: sink toward near-black indigo.
r -= down * 7
g -= down * 12
b -= down * 26
// Faint cyan bloom radiating from each source.
const bloom = glow[i] * glowGain
r += bloom * 10
g += bloom * 30
b += bloom * 44
const o = i * 4
data[o] = r // Uint8ClampedArray clamps out-of-range for us
data[o + 1] = g
data[o + 2] = b
data[o + 3] = 255
}
field.bctx.putImageData(image, 0, 0)
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = "high"
context.clearRect(0, 0, width, height)
context.drawImage(field.buffer, 0, 0, cols, rows, 0, 0, width, height)
}"use client"
import { useCallback, useEffect, useRef, useState } from "react"
/**
* The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing
* store, a rebuild on resize, a loop that stops when the stage scrolls out of
* view, pointer tracking with per-frame deltas, and teardown.
*
* A scene supplies two functions. `setup` builds whatever mutable state the
* animation owns and is re-run whenever the stage changes size, so the state can
* be sized to the stage without ever being resized in place. `draw` paints one
* frame from that state — it is called with the transform already scaled to
* device pixels, so every coordinate in it is a CSS pixel.
*/
export type ScenePointer = {
x: number
y: number
/** Position at the previous painted frame, so `x - lastX` is a frame delta. */
lastX: number
lastY: number
down: boolean
inside: boolean
}
export type SceneSetupContext = {
context: CanvasRenderingContext2D
width: number
height: number
dpr: number
}
export type SceneDrawContext<State> = SceneSetupContext & {
state: State
pointer: ScenePointer
/** Painted frames since the last rebuild. Useful for every-Nth-frame work. */
frame: number
}
export type CanvasSceneOptions<State> = {
setup: (context: SceneSetupContext) => State
draw: (context: SceneDrawContext<State>) => void
}
export type CanvasScene = {
/** The sizing element. Owns the pointer listeners and is what is observed. */
stageRef: (node: HTMLDivElement | null) => void
canvasRef: (node: HTMLCanvasElement | null) => void
/** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */
requestRender: () => void
}
/** Live `prefers-reduced-motion`. False during SSR and the first paint. */
export function useReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
setReduced(query.matches)
const onChange = () => setReduced(query.matches)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
}, [])
return reduced
}
export function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {
const reduced = useReducedMotion()
/*
* `draw` is usually an inline closure, so it is a new function on every
* render. Reading it through a ref keeps the loop from being torn down and
* the scene from being rebuilt each time the component re-renders.
*/
const optionsRef = useRef(options)
optionsRef.current = options
const stage = useRef<HTMLDivElement | null>(null)
const canvas = useRef<HTMLCanvasElement | null>(null)
/*
* Plain ref assignment, with no state behind it. React attaches refs during
* the commit phase, before passive effects run, so the effect below already
* sees both nodes on the first mount — which is why these used to bump a
* `mounted` counter for nothing: the two `setMounted` calls batched into one
* re-render, the counter went 0 → 2, and the effect's dependency on it tore
* the live scene down and rebuilt it. Every scene was constructed, measured
* and warmed twice on every mount, four times under StrictMode in dev.
*
* The requirement this trades for that: a consumer must render the stage and
* the canvas unconditionally, in the same commit as the component itself. All
* thirteen do. Gating the canvas behind a flag would leave the effect bailing
* on the null guard with nothing to re-run it.
*/
const stageRef = useCallback((node: HTMLDivElement | null) => {
stage.current = node
}, [])
const canvasRef = useCallback((node: HTMLCanvasElement | null) => {
canvas.current = node
}, [])
/** Set once the scene is live, so `requestRender` before that is a no-op. */
const render = useRef<(() => void) | null>(null)
const requestRender = useCallback(() => render.current?.(), [])
useEffect(() => {
const stageNode = stage.current
const canvasNode = canvas.current
if (!stageNode || !canvasNode) return
const context = canvasNode.getContext("2d")
if (!context) return
const pointer: ScenePointer = {
x: 0,
y: 0,
lastX: 0,
lastY: 0,
down: false,
inside: false,
}
let state: State | null = null
let width = 0
let height = 0
let dpr = 1
let frame = 0
let loop = 0
let pending = 0
let visible = true
/** Rebuild the backing store and the scene state for the current size. */
const measure = () => {
// `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is
// post-transform, so a scene sitting inside a scaled ancestor measured its
// own frame at the scaled size, sized the backing store to that, and then
// had CSS scale the result a second time — the scene ran at a fraction of
// the box it was drawn into. The catalogue's scaled-poster branch is the
// one place that happens, and it is reachable again the moment an
// animation is registered without a card composition. These two properties
// are the untransformed layout box; both are integers, which is what the
// rounding below already reduced the rect to.
const nextWidth = Math.max(1, stageNode.offsetWidth)
const nextHeight = Math.max(1, stageNode.offsetHeight)
const nextDpr = Math.min(2, window.devicePixelRatio || 1)
if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return
width = nextWidth
height = nextHeight
dpr = nextDpr
canvasNode.width = Math.round(width * dpr)
canvasNode.height = Math.round(height * dpr)
canvasNode.style.width = `${width}px`
canvasNode.style.height = `${height}px`
frame = 0
state = optionsRef.current.setup({ context, width, height, dpr })
}
const paint = () => {
if (!state) return
// Re-applied every frame: a scene is free to install its own transform
// for a cell or a sprite, and most do.
context.setTransform(dpr, 0, 0, dpr, 0, 0)
optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })
pointer.lastX = pointer.x
pointer.lastY = pointer.y
frame += 1
}
/** One frame on the next tick, coalescing however many were asked for. */
const paintOnce = () => {
if (pending) return
pending = requestAnimationFrame(() => {
pending = 0
measure()
paint()
})
}
render.current = paintOnce
const tick = () => {
loop = requestAnimationFrame(tick)
if (visible) paint()
}
const start = () => {
if (loop || reduced) return
loop = requestAnimationFrame(tick)
}
const stop = () => {
if (!loop) return
cancelAnimationFrame(loop)
loop = 0
}
const at = (event: PointerEvent) => {
const rect = stageNode.getBoundingClientRect()
// The rect is the right thing to subtract here — `clientX` is viewport
// space and so is the rect — but the difference comes back in *rendered*
// pixels, and a scene reads `pointer` in the scene pixels `measure()` set
// up from the untransformed box. Under a CSS scale those two disagree, so
// divide the transform back out. `rect.width / offsetWidth` is the scale
// actually in force, whatever produced it, and it is exactly 1 when there
// is none.
const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1
pointer.x = (event.clientX - rect.left) / (scale || 1)
pointer.y = (event.clientY - rect.top) / (scale || 1)
// A frozen loop still owes the user feedback for a drag.
if (reduced) paintOnce()
}
const onEnter = (event: PointerEvent) => {
pointer.inside = true
at(event)
pointer.lastX = pointer.x
pointer.lastY = pointer.y
}
const onMove = (event: PointerEvent) => {
pointer.inside = true
at(event)
}
const onDown = (event: PointerEvent) => {
pointer.down = true
at(event)
// Capture keeps a drag alive past the edge of the stage, which is where
// a hard throw naturally ends up.
stageNode.setPointerCapture(event.pointerId)
}
const onUp = (event: PointerEvent) => {
pointer.down = false
at(event)
if (stageNode.hasPointerCapture(event.pointerId)) {
stageNode.releasePointerCapture(event.pointerId)
}
}
const onLeave = () => {
pointer.inside = false
pointer.down = false
if (reduced) paintOnce()
}
stageNode.addEventListener("pointerenter", onEnter)
stageNode.addEventListener("pointermove", onMove)
stageNode.addEventListener("pointerdown", onDown)
stageNode.addEventListener("pointerup", onUp)
stageNode.addEventListener("pointercancel", onUp)
stageNode.addEventListener("pointerleave", onLeave)
const resizes = new ResizeObserver(() => paintOnce())
resizes.observe(stageNode)
/*
* An animation nobody can see is heat. The observer both pauses the loop
* and, on the way back in, repaints immediately rather than waiting a frame.
*/
const views = new IntersectionObserver(
(entries) => {
visible = entries.some((entry) => entry.isIntersecting)
if (visible) {
start()
paintOnce()
} else {
stop()
}
},
{ rootMargin: "120px" },
)
views.observe(stageNode)
measure()
paint()
start()
return () => {
render.current = null
stop()
if (pending) cancelAnimationFrame(pending)
resizes.disconnect()
views.disconnect()
stageNode.removeEventListener("pointerenter", onEnter)
stageNode.removeEventListener("pointermove", onMove)
stageNode.removeEventListener("pointerdown", onDown)
stageNode.removeEventListener("pointerup", onUp)
stageNode.removeEventListener("pointercancel", onUp)
stageNode.removeEventListener("pointerleave", onLeave)
}
}, [reduced])
return { stageRef, canvasRef, requestRender }
}