mirror of
https://github.com/midou36o/midou36o.github.io
synced 2026-11-14 10:53:11 +05:30
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
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();
|
|
}
|
|
}
|