Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/landing/src/lib/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export function withBase(path: string): string {
const base = import.meta.env.BASE_URL
if (path === "/") return base
return `${base}${path.replace(/^\//, "")}`
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
return `${base}/${path.replace(/^\//, "")}`
}
1 change: 1 addition & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"include-component-in-tag": true,
"bump-minor-pre-major": true,
"packages": {
"wavedance": {
"component": "wavedance",
Expand Down
18 changes: 18 additions & 0 deletions wavedance/src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,21 @@ describe("resolveConfig devicePixelRatio", () => {
expect(resolveConfig({ devicePixelRatio: 3, maxDevicePixelRatio: 2 }).devicePixelRatio).toBe(3)
})
})

describe("resolveConfig maxFps", () => {
it("defaults maxFps to 60", () => {
expect(resolveConfig({}).maxFps).toBe(60)
})

it("accepts an explicit cap", () => {
expect(resolveConfig({ maxFps: 30 }).maxFps).toBe(30)
})

it("treats 0 as uncapped", () => {
expect(resolveConfig({ maxFps: 0 }).maxFps).toBe(0)
})

it("clamps negative values to 0", () => {
expect(resolveConfig({ maxFps: -5 }).maxFps).toBe(0)
})
})
2 changes: 2 additions & 0 deletions wavedance/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { normalizeHexColor } from "./color"

const DEFAULT_GAP = 8
const DEFAULT_DOT_SIZE = 1
const DEFAULT_MAX_FPS = 60

export function clampOpacity(value: number | undefined, fallback = 1): number {
if (value === undefined) {
Expand Down Expand Up @@ -80,6 +81,7 @@ export function resolveConfig(config: WavedanceConfig = {}): ResolvedWavedanceCo
devicePixelRatio: config.devicePixelRatio ?? Math.min(autoDpr, maxDevicePixelRatio),
maxDevicePixelRatio,
maxDots: config.maxDots ?? 100_000,
maxFps: Math.max(0, config.maxFps ?? DEFAULT_MAX_FPS),
respectReducedMotion: config.respectReducedMotion ?? true,
}
}
39 changes: 39 additions & 0 deletions wavedance/src/core/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,42 @@ describe("FieldComputer", () => {
expect(second).toBe(first)
})
})

describe("FieldComputer static speed", () => {
const grid = buildGrid({
width: 300,
height: 200,
dotSize: 2,
gap: { x: 12, y: 12 },
dpr: 1,
maxDots: 10_000,
})

for (const animation of ["wave", "plasma", "arc", "ribbon"] as const) {
it(`${animation} is time-invariant when speed is 0`, () => {
const config = resolveConfig({ animation, [animation]: { speed: 0 } })
const field = new FieldComputer(config.maxDots, config)
const early = Float32Array.from(field.compute(grid, 0, 16))
const late = field.compute(grid, 5_000, 16)

for (let i = 0; i < grid.count; i++) {
expect(late[i]).toBe(early[i])
}
})
}

it("random is invariant after its first frame when speed is 0", () => {
const config = resolveConfig({ animation: "random", random: { speed: 0, seed: 11 } })
const field = new FieldComputer(config.maxDots, config)
const first = Float32Array.from(field.compute(grid, 0, 16))

field.compute(grid, 5_000, 16)
const later = field.compute(grid, 10_000, 5_000)

for (let i = 0; i < grid.count; i++) {
expect(later[i]).toBe(first[i])
expect(later[i]).toBeGreaterThanOrEqual(config.random.minOpacity)
expect(later[i]).toBeLessThanOrEqual(config.random.maxOpacity)
}
})
})
8 changes: 8 additions & 0 deletions wavedance/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ export interface WavedanceConfig {
maxDevicePixelRatio?: number
/** Safety cap on dot count. Default: 100000 */
maxDots?: number
/**
* Upper bound on drawn frames per second. Excess animation frames are
* skipped (the rAF loop stays alive) so average fps ≈ `maxFps`, and the
* skipped time folds into the next frame's delta. `0` disables the cap and
* draws on every animation frame. Default: 60
*/
maxFps?: number
/** Pause animation when prefers-reduced-motion is set. Default: true */
respectReducedMotion?: boolean
}
Expand All @@ -146,6 +153,7 @@ export interface ResolvedWavedanceConfig {
devicePixelRatio: number
maxDevicePixelRatio: number
maxDots: number
maxFps: number
respectReducedMotion: boolean
}

Expand Down
124 changes: 124 additions & 0 deletions wavedance/src/wavedance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,130 @@ describe("createWavedance", () => {
)
})

/** Fake-rAF harness: transparent background keeps clearRect as the per-draw signal. */
function frameHarness() {
const clearRect = vi.fn()
const ctx = {
fillStyle: "",
globalAlpha: 1,
fillRect: vi.fn(),
clearRect,
} as unknown as CanvasRenderingContext2D
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx)

const scheduled: FrameRequestCallback[] = []
const requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
scheduled.push(cb)
return scheduled.length
})
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame)
vi.stubGlobal("cancelAnimationFrame", vi.fn())

const drive = (frames: number, stepMs = 16) => {
for (let i = 0; i < frames; i++) {
const next = scheduled[scheduled.length - 1]
if (next) next((i + 1) * stepMs)
}
}
return { clearRect, requestAnimationFrame, drive }
}

it("caps drawn frames at maxFps while keeping the loop alive", () => {
const { clearRect, requestAnimationFrame, drive } = frameHarness()
const instance = createWavedance(container, {
animation: "wave",
maxFps: 30,
backgroundOpacity: 0,
})
drive(21) // 21 frames at 16ms spacing ≈ 62.5fps for 336ms

// ~30fps over ~336ms ≈ 10-11 draws (tolerant band — drift correction
// makes exact counts brittle), strictly fewer than one per rAF frame.
const draws = clearRect.mock.calls.length
expect(draws).toBeGreaterThanOrEqual(8)
expect(draws).toBeLessThanOrEqual(12)
// The rAF chain never stops: one schedule at start + one per driven frame.
expect(requestAnimationFrame).toHaveBeenCalledTimes(22)
instance.destroy()
})

it("maxFps 0 draws on every animation frame", () => {
const { clearRect, drive } = frameHarness()
const instance = createWavedance(container, {
animation: "wave",
maxFps: 0,
backgroundOpacity: 0,
})
drive(5)
expect(clearRect).toHaveBeenCalledTimes(5)
instance.destroy()
})

it("draws once and stops scheduling when the active speed is 0", () => {
const { clearRect, requestAnimationFrame, drive } = frameHarness()
const instance = createWavedance(container, {
animation: "wave",
wave: { speed: 0 },
maxFps: 0,
backgroundOpacity: 0,
})
drive(5)

expect(clearRect).toHaveBeenCalledTimes(1)
expect(requestAnimationFrame).toHaveBeenCalledTimes(1)
instance.destroy()
})

it("resumes from a static park when update raises the speed", () => {
const { clearRect, drive } = frameHarness()
const instance = createWavedance(container, {
animation: "wave",
wave: { speed: 0 },
maxFps: 0,
backgroundOpacity: 0,
})
drive(2)
expect(clearRect).toHaveBeenCalledTimes(1)

instance.update({ wave: { speed: 0.001 } })
// update() draws immediately…
expect(clearRect).toHaveBeenCalledTimes(2)
// …and the loop keeps drawing again.
drive(3)
expect(clearRect).toHaveBeenCalledTimes(5)
instance.destroy()
})

it("static random draws its seeded first frame once, then parks", () => {
const { clearRect, requestAnimationFrame, drive } = frameHarness()
const instance = createWavedance(container, {
animation: "random",
random: { speed: 0, seed: 11 },
maxFps: 0,
backgroundOpacity: 0,
})
drive(5)

expect(clearRect).toHaveBeenCalledTimes(1)
expect(requestAnimationFrame).toHaveBeenCalledTimes(1)
instance.destroy()
})

it("update draws immediately even inside a throttle window", () => {
const { clearRect, drive } = frameHarness()
const instance = createWavedance(container, {
animation: "wave",
maxFps: 30,
backgroundOpacity: 0,
})
drive(1)
expect(clearRect).toHaveBeenCalledTimes(1)

instance.update({ foreground: "#ffffff" })
expect(clearRect).toHaveBeenCalledTimes(2)
instance.destroy()
})

it("draws once and then idles when prefers-reduced-motion is set", () => {
const clearRect = vi.fn()
const ctx = {
Expand Down
34 changes: 30 additions & 4 deletions wavedance/src/wavedance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,25 @@ export function createWavedance(
let inView = true
let startTime = performance.now()
let lastFrameTime = startTime
// Clock of the last *drawn* frame, for the maxFps throttle. Distinct from
// `lastFrameTime`: skipped frames must keep accumulating delta for the
// delta-integrating animations. -Infinity guarantees the first frame draws.
let lastDrawTime = Number.NEGATIVE_INFINITY
let resizeObserver: ResizeObserver | null = null
let intersectionObserver: IntersectionObserver | null = null
let motionMediaQuery: MediaQueryList | null = null
let motionChangeHandler: ((event: MediaQueryListEvent) => void) | null = null
let visibilityHandler: (() => void) | null = null
let resizeTimer = 0

// With the active animation's speed at exactly 0 the computed field is
// time-invariant, so repainting is pure waste. Strict equality: negative
// speeds animate backwards and must not park.
const isStatic = (): boolean => resolved[resolved.animation].speed === 0

const shouldAnimate = (): boolean => {
if (resolved.respectReducedMotion && prefersReducedMotion()) return false
if (isStatic()) return false
return visible && inView
}

Expand Down Expand Up @@ -128,8 +138,16 @@ export function createWavedance(
// Resuming from an idle stretch: reset the frame clock so the delta-based
// animations don't jump on the first frame back.
if (idleDrawn) lastFrameTime = time
drawFrame(time)
idleDrawn = false
const interval = resolved.maxFps > 0 ? 1000 / resolved.maxFps : 0
const elapsed = time - lastDrawTime
if (interval === 0 || elapsed >= interval) {
drawFrame(time)
// Keep the fractional remainder so average fps ≈ maxFps; after a long
// gap (or the -Infinity first frame) snap to `time` — the remainder
// would be NaN or overcorrect.
lastDrawTime = elapsed < interval * 2 ? time - (elapsed % interval) : time
idleDrawn = false
}
ensureLoop()
} else if (grid && !idleDrawn) {
// Not animating (hidden, off-screen, or reduced-motion): draw one static
Expand All @@ -144,6 +162,7 @@ export function createWavedance(
running = true
startTime = performance.now()
lastFrameTime = startTime
lastDrawTime = Number.NEGATIVE_INFINITY
rafId = requestAnimationFrame(loop)
}

Expand All @@ -162,7 +181,9 @@ export function createWavedance(
resizeTimer = window.setTimeout(() => {
rebuildGrid()
if (grid) {
drawFrame(performance.now())
const now = performance.now()
drawFrame(now)
lastDrawTime = now
if (shouldAnimate()) {
idleDrawn = false
ensureLoop()
Expand Down Expand Up @@ -233,7 +254,12 @@ export function createWavedance(
}

if (grid) {
drawFrame(performance.now())
const now = performance.now()
// Pin the frame clock when resuming from a park, or the delta-based
// animations would integrate the whole parked stretch in one frame.
if (idleDrawn) lastFrameTime = now
drawFrame(now)
lastDrawTime = now
if (shouldAnimate()) {
idleDrawn = false
ensureLoop()
Expand Down
Loading