import { FluidField } from './fluid.ts'; import { createRenderer } from './renderer.ts'; import { SolidScene } from './scene.ts'; import type { Obstacle } from './types.ts'; const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); const PROTECTED_CONTENT = 'a, button, h1, h2, h3, h4, h5, h6, p, pre, blockquote, table, img'; const FRAME_CPU_BUDGET_MS = 50; const PERFORMANCE_WARMUP_MS = 1000; const PERFORMANCE_WINDOW_MS = 2000; const PERFORMANCE_MIN_FRAMES = 8; export class BackgroundController { private readonly fluid = new FluidField(); private readonly scene = new SolidScene(); private readonly renderer; private readonly softwareFallback: boolean; private performanceFallback = false; private sampledFrames = 0; private sampledCpuTime = 0; private sampleWindowStart = 0; private initialCheckComplete = false; private get still(): boolean { return this.softwareFallback || this.performanceFallback || this.reducedMotion.matches; } private readonly listeners = new AbortController(); private readonly observer: ResizeObserver; private readonly contentObserver: ResizeObserver; private readonly mutations: MutationObserver; private protectedElements: Element[] = []; private readonly darkMode = window.matchMedia('(prefers-color-scheme: dark)'); private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); private staticComposition = false; private obstacles: Obstacle[] | null = null; private animation = 0; private lastTime = 0; private time = 0; private cssWidth = 0; private cssHeight = 0; private dpr = 1; private pointerId: number | null = null; private pointerX = 0; private pointerY = 0; private previousX = 0; private previousY = 0; private hasPrevious = false; private disposed = false; constructor( private readonly canvas: HTMLCanvasElement, private readonly frame: HTMLElement, private readonly onPerformanceReady: () => void ) { const result = createRenderer(canvas); this.renderer = result.renderer; this.softwareFallback = !result.animated; canvas = this.canvas = result.canvas; if (this.softwareFallback) { canvas.style.pointerEvents = 'none'; canvas.style.touchAction = 'auto'; } this.observer = new ResizeObserver(this.resize); this.observer.observe(frame); this.contentObserver = new ResizeObserver(this.measureObstacles); this.mutations = new MutationObserver(this.observeContent); const options = { signal: this.listeners.signal }; canvas.addEventListener('pointermove', this.pointerMove, options); canvas.addEventListener('pointerdown', this.pointerDown, options); canvas.addEventListener('pointerup', this.endDrag, options); canvas.addEventListener('pointercancel', this.endDrag, options); canvas.addEventListener('lostpointercapture', this.endDrag, options); canvas.addEventListener('pointerleave', this.pointerLeave, options); window.addEventListener('resize', this.resize, options); document.addEventListener('visibilitychange', this.syncPlayback, options); this.reducedMotion.addEventListener('change', this.syncPlayback, options); this.darkMode.addEventListener('change', this.draw, options); const content = frame.parentElement; if (content) { this.mutations.observe(content, { childList: true, subtree: true, characterData: true }); this.observeContent(); content.addEventListener('introend', this.measureObstacles, { ...options, capture: true }); } window.addEventListener('scroll', this.measureObstacles, { ...options, passive: true, capture: true }); document.fonts.ready.then(() => { if (!this.disposed) this.measureObstacles(); }); this.resize(); this.syncPlayback(); } private resize = () => { if (this.disposed) return; const bounds = this.frame.getBoundingClientRect(); const width = Math.max(1, Math.round(bounds.width)); const height = Math.max(1, Math.round(bounds.height)); const dpr = Math.min(window.devicePixelRatio || 1, 2); if (width === this.cssWidth && height === this.cssHeight && dpr === this.dpr) return; this.resetPerformanceSampling(); this.cssWidth = width; this.cssHeight = height; this.dpr = dpr; this.canvas.width = Math.round(width * dpr); this.canvas.height = Math.round(height * dpr); this.endDrag(); // Higher-resolution pixels retain crisp, non-AA edges on the smaller models. const simulationScale = 320 / Math.max(width, height); const simulationWidth = Math.max(3, Math.round(width * simulationScale)); const simulationHeight = Math.max(3, Math.round(height * simulationScale)); const resetStatic = this.still && !this.staticComposition; if (this.still) this.time = 0; this.staticComposition = this.still; if ( resetStatic || simulationWidth !== this.fluid.width || simulationHeight !== this.fluid.height ) { this.fluid.resize(simulationWidth, simulationHeight, this.time); this.scene.resize(simulationWidth, simulationHeight, this.time); } this.obstacles = null; this.renderer.resize(simulationWidth, simulationHeight); this.measureObstacles(); }; private draw = () => { if (!this.disposed) { this.renderer.render(this.fluid, this.scene, this.time, this.dpr, this.darkMode.matches); } }; private observeContent = () => { if (this.disposed) return; const content = this.frame.parentElement; if (!content) return; this.contentObserver.disconnect(); this.contentObserver.observe(content); this.protectedElements = Array.from(content.querySelectorAll(PROTECTED_CONTENT)); for (const element of this.protectedElements) { this.contentObserver.observe(element); } this.measureObstacles(); }; private measureObstacles = () => { if (this.disposed || !this.fluid.width || !this.fluid.height) return; const content = this.frame.parentElement; if (!content) return; const frame = this.frame.getBoundingClientRect(); const obstacles: Obstacle[] = []; const cellScale = Math.min(frame.width / this.fluid.width, frame.height / this.fluid.height); const offsetX = (frame.width - this.fluid.width * cellScale) / 2; const offsetY = (frame.height - this.fluid.height * cellScale) / 2; const clearance = 8; for (const element of this.protectedElements) { if (element.parentElement?.closest(PROTECTED_CONTENT)) continue; const range = document.createRange(); range.selectNodeContents(element); const rect = element.matches('h1, h2, h3, h4, h5, h6, p') ? range.getBoundingClientRect() : element.getBoundingClientRect(); if ( !rect.width || !rect.height || rect.right <= frame.left || rect.left >= frame.right || rect.bottom <= frame.top || rect.top >= frame.bottom ) continue; obstacles.push({ minX: Math.max(0, (rect.left - frame.left - clearance - offsetX) / cellScale), maxX: Math.min( this.fluid.width, (rect.right - frame.left + clearance - offsetX) / cellScale ), minY: Math.max(0, (frame.bottom - rect.bottom - clearance - offsetY) / cellScale), maxY: Math.min( this.fluid.height, (frame.bottom - rect.top + clearance - offsetY) / cellScale ) }); } if ( this.still && this.obstacles?.length === obstacles.length && obstacles.every((obstacle, i) => { const previous = this.obstacles![i]; return ( obstacle.minX === previous.minX && obstacle.maxX === previous.maxX && obstacle.minY === previous.minY && obstacle.maxY === previous.maxY ); }) ) return; this.obstacles = obstacles; this.scene.setObstacles(obstacles); if (this.still) this.scene.renderPreset(); else this.scene.update(0, this.time); this.draw(); }; private resetPerformanceSampling(): void { this.sampledFrames = 0; this.sampledCpuTime = 0; this.sampleWindowStart = performance.now() + PERFORMANCE_WARMUP_MS; } private finishInitialCheck(): void { if (this.initialCheckComplete) return; this.initialCheckComplete = true; this.onPerformanceReady(); } private syncPlayback = () => { cancelAnimationFrame(this.animation); this.animation = 0; this.lastTime = 0; this.resetPerformanceSampling(); if (this.disposed) return; if (this.still) { this.endDrag(); if (!this.staticComposition) { this.staticComposition = true; this.time = 0; this.fluid.resize(this.fluid.width, this.fluid.height, this.time); this.scene.renderPreset(); this.draw(); } this.finishInitialCheck(); return; } this.staticComposition = false; if (document.hidden) { this.endDrag(); return; } this.animation = requestAnimationFrame(this.tick); }; private tick = (now: number) => { if (this.disposed) return; if (document.hidden || this.still) { this.syncPlayback(); return; } const started = performance.now(); if (Math.min(window.devicePixelRatio || 1, 2) !== this.dpr) this.resize(); const elapsed = this.lastTime ? Math.min((now - this.lastTime) / 16.6667, 1.7) : 1; this.lastTime = now; this.time += elapsed / 60; this.scene.update(elapsed, this.time); this.fluid.step(elapsed, this.time, this.scene); this.draw(); const cpuTime = performance.now() - started; if (started >= this.sampleWindowStart) { this.sampledCpuTime += cpuTime; this.sampledFrames++; if ( started - this.sampleWindowStart >= PERFORMANCE_WINDOW_MS && this.sampledFrames >= PERFORMANCE_MIN_FRAMES ) { if (this.sampledCpuTime / this.sampledFrames > FRAME_CPU_BUDGET_MS) { this.performanceFallback = true; this.canvas.style.pointerEvents = 'none'; this.canvas.style.touchAction = 'auto'; this.syncPlayback(); return; } this.sampledFrames = 0; this.sampledCpuTime = 0; this.sampleWindowStart = started; this.finishInitialCheck(); } } this.animation = requestAnimationFrame(this.tick); }; private pointerPosition(event: PointerEvent) { const bounds = this.canvas.getBoundingClientRect(); const cellScale = Math.min(bounds.width / this.fluid.width, bounds.height / this.fluid.height); const offsetX = (bounds.width - this.fluid.width * cellScale) / 2; const offsetY = (bounds.height - this.fluid.height * cellScale) / 2; this.pointerX = clamp((event.clientX - bounds.left - offsetX) / cellScale, 0, this.fluid.width); this.pointerY = clamp( (bounds.bottom - event.clientY - offsetY) / cellScale, 0, this.fluid.height ); } private interact(event: PointerEvent) { const x = clamp(this.pointerX, 1, this.fluid.width - 2); const y = clamp(this.pointerY, 1, this.fluid.height - 2); if (this.hasPrevious) { this.fluid.interact(x, y, x - this.previousX, y - this.previousY, event.buttons !== 0); } this.previousX = x; this.previousY = y; this.hasPrevious = true; } private pointerMove = (event: PointerEvent) => { if (this.still) return; if (this.scene.drag.body) { if (event.pointerId === this.pointerId) { this.pointerPosition(event); this.scene.moveDrag(this.pointerX, this.pointerY); } return; } this.pointerPosition(event); this.canvas.style.cursor = this.scene.pickBody(this.pointerX, this.pointerY) ? 'grab' : 'default'; this.interact(event); }; private pointerDown = (event: PointerEvent) => { if (this.still || !event.isPrimary || event.button !== 0 || this.pointerId !== null) return; this.pointerPosition(event); this.pointerId = event.pointerId; this.canvas.setPointerCapture(event.pointerId); this.hasPrevious = false; if (this.scene.beginDrag(this.pointerX, this.pointerY)) { this.canvas.style.cursor = 'grabbing'; } else { this.interact(event); } }; private endDrag = (event?: PointerEvent) => { if (event && this.pointerId !== null && event.pointerId !== this.pointerId) return; const id = this.pointerId; this.pointerId = null; this.scene.endDrag(); this.hasPrevious = false; this.canvas.style.cursor = 'default'; if (id !== null && this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id); }; private pointerLeave = () => { if (!this.scene.drag.body) this.hasPrevious = false; }; destroy() { this.disposed = true; cancelAnimationFrame(this.animation); this.listeners.abort(); this.observer.disconnect(); this.contentObserver.disconnect(); this.mutations.disconnect(); this.endDrag(); this.renderer.destroy(); } }