@fluritheme/fluri
Version:
Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.
881 lines • 37.2 kB
JavaScript
import { FrameBudget, particleBudget } from './performance/Budget.js';
import { seedDeposits } from './seedDeposits.js';
import { Actors } from './actors/Actors.js';
import { cloneConfig, patchConfig, resolveConfig, surfaceOptionsSchema } from './config.js';
import { SurfaceRegistry } from './dom/SurfaceRegistry.js';
import { ParticleSystem, depthAt, seededRandom, localToWorld, totalMass } from './physics/index.js';
import { CanvasRenderer } from './rendering/CanvasRenderer.js';
export function createWeather(options = {}) {
return new WeatherEngine(options);
}
function freezeConfig(value) {
if (value && typeof value === 'object' && !('nodeType' in value)) {
for (const child of Object.values(value))
freezeConfig(child);
Object.freeze(value);
}
return value;
}
class WeatherEngine {
config;
pluginConfig;
state = 'idle';
renderer;
registry;
particles;
actors;
root;
listeners = new Map();
plugins = new Map();
registrations = new Map();
cleanups = [];
raf = 0;
geometryRAF = 0;
geometryDirty = false;
lastFrame = 0;
time = 0;
accumulator = 0;
quality = 1;
frameBudget = new FrameBudget();
coarse = false;
effectiveBudget = 0;
media;
toggle;
pointer;
gatheringPointer;
lastBrush = 0;
depositSeed = 0;
eventClock = 0;
pendingSnow = { deposits: 0, transfers: 0, slides: 0, slideMass: 0 };
metrics = {
state: 'idle',
fps: 0,
frameTime: 0,
simTime: 0,
renderTime: 0,
geometryScanTime: 0,
particleCount: 0,
surfaceCount: 0,
collisionChecks: 0,
droppedFrames: 0,
quality: 1,
snowMass: 0,
snowballCount: 0,
snowmanCount: 0,
approximateMemory: 0,
particleBudget: 0,
textureStatus: 'idle',
};
constructor(options) {
this.config = resolveConfig(options);
this.pluginConfig = freezeConfig(cloneConfig(this.config));
this.particles = new ParticleSystem(this.config.seed);
this.quality =
this.config.performance.preferredQuality === 'low'
? 0.35
: this.config.performance.preferredQuality === 'medium'
? 0.65
: 1;
if (this.config.autoStart && typeof document !== 'undefined')
this.start();
}
assertAlive() {
if (this.state === 'destroyed')
throw new Error('This Fluri instance has been destroyed');
}
context() {
return {
trigger: (type, options) => this.trigger(type, options),
getConfig: () => this.pluginConfig,
getSurfaces: () => this.registry?.surfaces ?? [],
emit: (e, d) => this.emit(e, d),
spawnClump: (...args) => this.particles.spawnClump(...args),
};
}
emit(event, data) {
for (const callback of this.listeners.get(event) ?? []) {
try {
callback(data);
}
catch (error) {
if (event !== 'error')
this.emit('error', { source: 'event', event, error });
}
}
}
on(event, callback) {
this.assertAlive();
if (typeof callback !== 'function')
throw new TypeError('Event callback must be a function');
const set = this.listeners.get(event) ?? new Set();
set.add(callback);
this.listeners.set(event, set);
return () => set.delete(callback);
}
start() {
this.assertAlive();
if (this.state === 'running')
return;
if (!this.renderer)
this.initialize();
this.state = 'running';
this.lastFrame = 0;
this.accumulator = 0;
this.syncControls();
this.emit('start');
this.schedule();
}
initialize() {
if (typeof document === 'undefined')
throw new Error('Call Fluri.start() in a browser after the document is ready');
const doc = this.config.container?.ownerDocument ?? document;
const win = doc.defaultView;
if (!win || !doc.body)
throw new Error('Fluri needs an attached browser document with a body');
const root = this.config.container ?? doc.body;
if (!root.isConnected)
throw new Error('Fluri container must be connected');
this.root = root;
this.renderer = new CanvasRenderer(doc, (error) => this.emit('error', { source: 'texture', error }), () => this.refreshStaticGeometry(), root);
this.renderer.resize(this.config.performance.maxDPR);
this.registry = new SurfaceRegistry(root, () => this.config, (event, data) => this.emit(event, data), (surface, reason) => this.shed(surface, reason), () => this.refreshStaticGeometry());
for (const [element, options] of this.registrations)
this.registry.register(element, options);
try {
this.registry.start();
}
catch (error) {
this.renderer.destroy();
this.renderer = undefined;
this.registry.destroy();
this.registry = undefined;
throw error;
}
this.actors = new Actors(this.context());
this.media = win.matchMedia('(prefers-reduced-motion: reduce)');
this.coarse = win.matchMedia('(pointer: coarse)').matches;
const visibility = () => {
this.gatheringPointer = undefined;
this.pointer = undefined;
this.actors?.pointer({
x: 0,
y: 0,
vx: 0,
vy: 0,
pressed: false,
shiftKey: false,
kind: 'cancel',
time: this.time,
});
this.cancel();
this.lastFrame = 0;
this.accumulator = 0;
if (!this.suspended())
this.schedule();
this.syncControls();
};
this.listen(doc, 'visibilitychange', visibility);
this.media.addEventListener('change', visibility);
this.cleanups.push(() => this.media?.removeEventListener('change', visibility));
this.listen(win, 'resize', () => {
this.renderer?.resize(this.config.performance.adaptive && this.quality < 0.65
? Math.min(1.25, this.config.performance.maxDPR)
: this.config.performance.maxDPR);
this.registry?.invalidate();
this.refreshStaticGeometry();
});
this.listen(doc, 'scroll', () => this.registry?.invalidateGeometry(), {
capture: true,
passive: true,
});
if (win.visualViewport) {
this.listen(win.visualViewport, 'resize', () => {
this.renderer?.resize(this.config.performance.adaptive && this.quality < 0.65
? Math.min(1.25, this.config.performance.maxDPR)
: this.config.performance.maxDPR);
this.registry?.invalidate();
});
this.listen(win.visualViewport, 'scroll', () => this.registry?.invalidate(), {
passive: true,
});
}
const handle = (event) => {
if (event instanceof win.PointerEvent)
this.handlePointer(event);
};
for (const name of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'])
this.listen(doc, name, handle, { passive: false });
const preventGatherSelection = (event) => {
if (this.gatheringPointer !== undefined && this.actors?.gathering)
event.preventDefault();
};
this.listen(doc, 'selectstart', preventGatherSelection, { capture: true });
this.listen(doc, 'dragstart', preventGatherSelection, { capture: true });
this.listen(win, 'blur', () => this.cancelGathering());
this.listen(doc, 'keyup', (event) => {
if (event instanceof win.KeyboardEvent && event.key === 'Shift')
this.cancelGathering();
});
for (const plugin of this.plugins.keys())
this.setupPlugin(plugin);
this.syncControls();
this.emit('ready');
}
listen(target, name, callback, options) {
target.addEventListener(name, callback, options);
this.cleanups.push(() => target.removeEventListener(name, callback, options));
}
refreshStaticGeometry() {
this.geometryDirty = true;
const win = this.root?.ownerDocument.defaultView;
if (!win ||
this.geometryRAF ||
!(this.state === 'paused' || (this.state === 'running' && this.suspended())))
return;
this.geometryRAF = win.requestAnimationFrame(() => {
this.geometryRAF = 0;
if (this.state === 'paused' || (this.state === 'running' && this.suspended())) {
this.registry?.update(win.performance.now() / 1000, false);
this.render();
this.geometryDirty = false;
}
});
}
suspended() {
return (this.root?.ownerDocument.hidden === true ||
(this.media?.matches === true && this.config.accessibility.reducedMotion === 'respect'));
}
schedule() {
if (this.state !== 'running' || this.raf || this.suspended())
return;
const win = this.root?.ownerDocument.defaultView;
if (win)
this.raf = win.requestAnimationFrame((timestamp) => this.frame(timestamp));
}
cancel() {
const win = this.root?.ownerDocument.defaultView;
if (this.raf)
win?.cancelAnimationFrame(this.raf);
this.raf = 0;
}
frame(timestamp) {
this.raf = 0;
if (this.state !== 'running' || this.suspended())
return;
const interval = 1000 / this.config.performance.maxFPS;
const elapsed = this.lastFrame ? timestamp - this.lastFrame : interval;
if (elapsed < interval - 0.8) {
// A simulation FPS cap must not leave deposits behind a scrolling or moving interface.
// Geometry is cheap and invalidated independently; physics still keeps its configured rate.
if (this.geometryDirty) {
this.registry?.update(timestamp / 1000);
this.render();
this.geometryDirty = false;
}
this.schedule();
return;
}
this.lastFrame = timestamp;
const begin = performance.now();
const dt = Math.min(0.1, elapsed / 1000);
this.accumulator += dt;
this.renderer?.resize(this.config.performance.adaptive && this.quality < 0.65
? Math.min(1.25, this.config.performance.maxDPR)
: this.config.performance.maxDPR);
this.registry?.update(timestamp / 1000);
this.geometryDirty = false;
const surfaces = this.registry?.surfaces ?? [];
const reduced = this.media?.matches && this.config.accessibility.reducedMotion === 'reduce';
let quality = reduced ? Math.min(0.15, this.quality) : this.quality;
const doc = this.root?.ownerDocument, container = this.config.container;
const rootRect = container && container !== doc?.body && container !== doc?.documentElement
? container.getBoundingClientRect()
: undefined;
const left = Math.max(0, rootRect?.left ?? 0), top = Math.max(0, rootRect?.top ?? 0);
const emission = rootRect
? {
x: left,
y: top,
width: Math.max(0, Math.min(this.renderer?.width ?? 0, rootRect.right) - left),
height: Math.max(0, Math.min(this.renderer?.height ?? 0, rootRect.bottom) - top),
}
: undefined;
this.effectiveBudget = particleBudget(this.config.snow.particleCount, (emission?.width ?? this.renderer?.width ?? 0) *
(emission?.height ?? this.renderer?.height ?? 0), this.coarse, this.config.performance.adaptive);
quality *= this.effectiveBudget / Math.max(1, this.config.snow.particleCount);
const simBegin = performance.now();
let collisionChecks = 0, deposits = 0, transfers = 0, slides = 0, slideMass = 0, steps = 0;
while (this.accumulator >= 1 / 60 && steps < 5) {
const m = this.particles.step(1 / 60, this.time, this.renderer?.width ?? 1, this.renderer?.height ?? 1, this.config, surfaces, quality, emission);
collisionChecks += m.collisionChecks;
deposits += m.deposits;
transfers += m.transfers;
slides += m.slides;
slideMass += m.slideMass;
this.actors?.update(1 / 60, this.time);
for (const plugin of this.plugins.keys()) {
try {
plugin.update?.(1 / 60, this.time);
}
catch (error) {
this.failPlugin(plugin, error);
}
}
this.time += 1 / 60;
this.accumulator -= 1 / 60;
steps++;
}
if (this.accumulator >= 1 / 60) {
this.metrics.droppedFrames++;
this.accumulator = 0;
}
this.metrics.simTime = performance.now() - simBegin;
this.pendingSnow.deposits += deposits;
this.pendingSnow.transfers += transfers;
this.pendingSnow.slides += slides;
this.pendingSnow.slideMass += slideMass;
this.eventClock += dt;
if (this.eventClock >= 0.1) {
this.eventClock %= 0.1;
if (this.pendingSnow.deposits)
this.emit('snow:deposit', { count: this.pendingSnow.deposits });
if (this.pendingSnow.transfers)
this.emit('snow:transfer', { count: this.pendingSnow.transfers });
if (this.pendingSnow.slides)
this.emit('snow:slide', {
count: this.pendingSnow.slides,
mass: this.pendingSnow.slideMass,
reason: 'motion',
});
this.pendingSnow = { deposits: 0, transfers: 0, slides: 0, slideMass: 0 };
}
this.render();
const work = performance.now() - begin;
if (this.config.performance.adaptive) {
const previous = this.quality;
const cap = this.config.performance.preferredQuality === 'low'
? 0.35
: this.config.performance.preferredQuality === 'medium'
? 0.65
: 1;
this.quality = Math.min(cap, this.frameBudget.observe(work, elapsed, interval, this.quality));
if (previous !== this.quality)
this.emit('quality:changed', {
quality: this.quality,
particleBudget: this.effectiveBudget,
});
}
this.metrics.fps = this.metrics.fps
? this.metrics.fps * 0.9 + (1000 / Math.max(1, elapsed)) * 0.1
: 1000 / elapsed;
this.metrics.frameTime = elapsed;
this.metrics.geometryScanTime = this.registry?.geometryScanTime ?? 0;
this.metrics.collisionChecks = collisionChecks;
this.schedule();
}
render() {
if (!this.renderer)
return;
if (this.state === 'stopped') {
this.renderer.clear();
return;
}
const begin = performance.now();
this.renderer.render(this.particles.particles, this.registry?.surfaces ?? [], this.config, this.time, [...this.plugins.keys()].map((plugin) => ({
name: plugin.name,
render: (ctx, time) => {
try {
plugin.render?.(ctx, time);
}
catch (error) {
this.failPlugin(plugin, error);
}
},
})), (ctx, time) => this.actors?.render(ctx, time));
this.metrics.renderTime = performance.now() - begin;
}
pause() {
this.assertAlive();
if (this.state !== 'running')
return;
this.state = 'paused';
this.cancel();
this.gatheringPointer = undefined;
this.pointer = undefined;
this.actors?.pointer({
x: 0,
y: 0,
vx: 0,
vy: 0,
pressed: false,
shiftKey: false,
kind: 'cancel',
time: this.time,
});
this.syncControls();
this.emit('pause');
}
resume() {
this.assertAlive();
if (this.state === 'running')
return;
if (!this.renderer) {
this.start();
return;
}
this.state = 'running';
this.lastFrame = 0;
this.accumulator = 0;
this.syncControls();
this.emit('resume');
this.schedule();
}
stop() {
this.assertAlive();
this.state = 'stopped';
this.cancel();
this.gatheringPointer = undefined;
this.particles.clear();
this.actors?.clear();
for (const surface of this.registry?.surfaces ?? [])
surface.profile.fill(0);
this.renderer?.clear();
this.syncControls();
this.emit('stop');
}
destroy() {
if (this.state === 'destroyed')
return;
this.cancel();
this.gatheringPointer = undefined;
if (this.geometryRAF)
this.root?.ownerDocument.defaultView?.cancelAnimationFrame(this.geometryRAF);
this.geometryRAF = 0;
for (const cleanup of this.cleanups.splice(0))
cleanup();
for (const [plugin, cleanup] of this.plugins) {
this.safeCleanup(cleanup, plugin.name);
this.safeCleanup(() => plugin.destroy?.(), plugin.name);
}
this.plugins.clear();
this.registry?.destroy();
this.renderer?.destroy();
this.actors?.destroy();
this.particles.clear();
this.toggle?.remove();
this.registrations.clear();
this.state = 'destroyed';
this.emit('destroy');
this.listeners.clear();
this.root = undefined;
this.renderer = undefined;
this.registry = undefined;
this.actors = undefined;
this.media = undefined;
}
updateConfig(patch) {
this.assertAlive();
const next = patchConfig(this.config, patch);
if (this.renderer && next.container !== this.config.container)
throw new Error('Create a new Fluri instance to change its container');
const doc = this.root?.ownerDocument;
if (doc)
for (const selector of [...next.dom.include, ...next.dom.exclude])
doc.querySelector(selector);
const needsObserver = next.dom.observeMutations !== this.config.dom.observeMutations;
const seedChanged = next.seed !== this.config.seed;
this.config = next;
if (!next.interactions.snowballs.enabled)
this.cancelGathering();
this.pluginConfig = freezeConfig(cloneConfig(next));
if (seedChanged) {
this.particles = new ParticleSystem(next.seed);
}
this.particles.setIntensity(next.snow.intensity);
const preferred = next.performance.preferredQuality;
this.quality = preferred === 'low' ? 0.35 : preferred === 'medium' ? 0.65 : 1;
this.registry?.invalidate();
if (needsObserver)
this.registry?.refreshObservers();
this.renderer?.resize(next.performance.maxDPR);
this.syncControls();
this.cancel();
this.lastFrame = 0;
this.render();
this.schedule();
}
getConfig() {
return cloneConfig(this.config);
}
setPreset(name, overrides = {}) {
const container = this.config.container;
const next = resolveConfig({ ...overrides, preset: name, ...(container ? { container } : {}) });
this.updateConfig(next);
}
setIntensity(amount) {
this.updateConfig({ snow: { intensity: amount } });
}
selected(selector) {
if (selector !== undefined && this.root)
this.root.ownerDocument.querySelector(selector);
return (this.registry?.surfaces ?? []).filter((s) => !selector || s.element.matches(selector));
}
clearAccumulation(selector) {
this.assertAlive();
for (const s of this.selected(selector))
s.profile.fill(0);
this.render();
}
addSnow(amount, selector) {
this.assertAlive();
if (!Number.isFinite(amount) || amount < 0 || amount > 80)
throw new RangeError('addSnow depth must be between 0 and 80 CSS pixels');
for (const s of this.selected(selector)) {
if (!s.accumulation || s.material === 'no-accumulation')
continue;
seedDeposits(s, amount, Math.min(s.maxDepth, this.config.snow.accumulation.maxDepth), (this.config.seed + Math.imul(++this.depositSeed, 0x9e3779b9)) >>> 0);
}
this.render();
}
registerSurface(element, options = {}) {
this.assertAlive();
if (!element ||
element.nodeType !== 1 ||
typeof element.getBoundingClientRect !== 'function' ||
!element.ownerDocument)
throw new TypeError('registerSurface expects an Element');
const doc = this.root?.ownerDocument ?? this.config.container?.ownerDocument;
if (doc && element.ownerDocument !== doc)
throw new TypeError('A surface must belong to the Fluri instance document');
options = surfaceOptionsSchema.parse(options);
this.registrations.set(element, { ...options });
this.registry?.register(element, options);
return () => this.unregisterSurface(element);
}
unregisterSurface(element) {
this.assertAlive();
this.registrations.delete(element);
this.registry?.unregister(element);
}
trigger(type, options = {}) {
this.assertAlive();
if (!['plough', 'gust', 'snowburst', 'sparkle', 'snowman', 'snowball'].includes(type))
throw new TypeError('Unknown Fluri trigger');
if (!options || typeof options !== 'object')
throw new TypeError('Trigger options must be an object');
for (const key of Object.keys(options))
if (!['selector', 'surface', 'direction', 'x', 'y', 'radius', 'speed', 'style'].includes(key))
throw new TypeError(`Unknown trigger option: ${key}`);
if (options.style !== undefined && !['classic', 'minimal', 'festive'].includes(options.style))
throw new TypeError('Unknown plough style');
if (options.direction !== undefined && !['left', 'right'].includes(options.direction))
throw new TypeError('Unknown trigger direction');
if (options.speed !== undefined &&
(!Number.isFinite(options.speed) || options.speed < 10 || options.speed > 600))
throw new RangeError('Plough speed must be between 10 and 600');
if (options.radius !== undefined && (options.radius < 1 || options.radius > 100))
throw new RangeError('Snowball radius must be between 1 and 100');
if (options.surface !== undefined && options.surface.nodeType !== 1)
throw new TypeError('Trigger surface must be an Element');
if (options.selector) {
if (typeof options.selector !== 'string' || options.selector.length > 512)
throw new TypeError('Invalid trigger selector');
this.root?.ownerDocument.querySelector(options.selector);
}
for (const value of [options.x, options.y, options.radius])
if (value !== undefined && !Number.isFinite(value))
throw new TypeError('Trigger coordinates must be finite');
const x = options.x ?? (this.renderer?.width ?? 0) / 2, y = options.y ?? (this.renderer?.height ?? 0) / 3;
if (type === 'gust')
this.particles.gust();
else if (type === 'snowburst' || type === 'sparkle')
this.particles.burst(x, y, 70, type === 'sparkle' ? 'sparkle' : 'flake');
else
this.actors?.trigger(type, options);
if (type === 'snowburst')
this.actors?.trigger(type, options);
for (const plugin of this.plugins.keys()) {
try {
plugin.trigger?.(type, options);
}
catch (error) {
this.failPlugin(plugin, error);
}
}
this.render();
}
getMetrics() {
const surfaces = this.registry?.surfaces ?? [];
return {
...this.metrics,
particleBudget: Math.floor(this.effectiveBudget * this.quality),
textureStatus: this.renderer?.textureStatus ?? (this.state === 'destroyed' ? 'destroyed' : 'idle'),
state: this.state === 'running' && this.suspended() ? 'paused' : this.state,
particleCount: this.particles.particles.length,
surfaceCount: surfaces.length,
quality: this.quality,
snowMass: surfaces.reduce((sum, s) => sum + totalMass(s.profile), 0),
snowballCount: this.actors?.counts.snowballs ?? 0,
snowmanCount: this.actors?.counts.snowmen ?? 0,
approximateMemory: this.particles.particles.length * 128 +
surfaces.reduce((n, s) => n + s.profile.byteLength + 256, 0),
};
}
use(plugin) {
this.assertAlive();
if (!plugin || typeof plugin.name !== 'string' || !plugin.name)
throw new TypeError('A plugin needs a name');
if ([...this.plugins.keys()].some((p) => p.name === plugin.name))
throw new Error(`Plugin already registered: ${plugin.name}`);
this.plugins.set(plugin, undefined);
if (this.renderer)
this.setupPlugin(plugin);
return () => {
if (this.plugins.has(plugin)) {
const cleanup = this.plugins.get(plugin);
this.plugins.delete(plugin);
this.safeCleanup(cleanup, plugin.name);
this.safeCleanup(() => plugin.destroy?.(), plugin.name);
this.render();
}
};
}
setupPlugin(plugin) {
try {
this.plugins.set(plugin, plugin.setup?.(this.context()) ?? undefined);
}
catch (error) {
this.failPlugin(plugin, error);
}
}
safeCleanup(cleanup, name) {
try {
cleanup?.();
}
catch (error) {
this.emit('error', { source: 'cleanup', name, error });
}
}
failPlugin(plugin, error) {
const cleanup = this.plugins.get(plugin);
this.plugins.delete(plugin);
this.safeCleanup(cleanup, plugin.name);
this.safeCleanup(() => plugin.destroy?.(), plugin.name);
this.emit('error', { source: 'plugin', name: plugin.name, error });
}
shed(surface, reason = 'removed') {
const mass = totalMass(surface.profile);
if (mass > 0)
this.emit('snow:slide', { surfaceId: surface.id, mass, reason });
for (let i = 0; i < surface.profile.length; i += 4) {
let mass = 0;
for (let j = i; j < Math.min(i + 4, surface.profile.length); j++) {
mass += surface.profile[j] ?? 0;
surface.profile[j] = 0;
}
if (mass > 0) {
const pos = localToWorld(surface, (i + Math.min(4, surface.profile.length - i) / 2) * surface.binSize, -3);
this.particles.spawnClump(pos.x, pos.y, mass, surface.velocity.x, surface.velocity.y + 12);
}
}
}
handlePointer(event) {
if (this.state !== 'running' || this.suspended() || !this.root)
return;
if (this.gatheringPointer !== undefined && event.pointerId !== this.gatheringPointer)
return;
if (event.type === 'pointerdown' && event.button !== 0)
return;
if (event.type === 'pointerup' || event.type === 'pointercancel') {
const previous = this.pointer;
const dt = Math.max(1 / 240, Math.min(0.1, event.timeStamp / 1000 - (previous?.time ?? event.timeStamp / 1000)));
this.actors?.pointer({
x: event.clientX,
y: event.clientY,
vx: previous ? (event.clientX - previous.x) / dt : 0,
vy: previous ? (event.clientY - previous.y) / dt : 0,
pressed: false,
shiftKey: event.shiftKey,
kind: event.type === 'pointerup' ? 'up' : 'cancel',
time: this.time,
});
this.pointer = undefined;
this.gatheringPointer = undefined;
return;
}
const rect = this.root.getBoundingClientRect();
const scoped = this.root !== this.root.ownerDocument.body;
if (scoped &&
(event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom)) {
this.cancelGathering();
return;
}
if (event.target instanceof Element &&
event.target.closest('[data-fluri-root], [data-weather-ignore], input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"]')) {
this.cancelGathering();
return;
}
const now = event.timeStamp / 1000;
const previous = this.pointer;
const dt = Math.max(1 / 240, Math.min(0.1, now - (previous?.time ?? now - 1 / 60)));
const vx = previous ? (event.clientX - previous.x) / dt : 0, vy = previous ? (event.clientY - previous.y) / dt : 0;
const kind = event.type === 'pointerdown'
? 'down'
: event.type === 'pointerup'
? 'up'
: event.type === 'pointercancel'
? 'cancel'
: 'move';
this.actors?.pointer({
x: event.clientX,
y: event.clientY,
vx,
vy,
pressed: event.buttons !== 0,
shiftKey: event.shiftKey,
kind,
time: this.time,
});
if (this.actors?.gathering && event.shiftKey && event.buttons === 1) {
this.gatheringPointer = event.pointerId;
// Only an accepted snow gesture owns selection; ordinary page input remains native.
if (event.cancelable)
event.preventDefault();
}
else
this.gatheringPointer = undefined;
if (kind === 'cancel' || kind === 'up') {
this.pointer = undefined;
return;
}
const brush = this.config.interactions.brush;
const intentional = event.pointerType === 'mouse'
? brush.activation === 'hover' || event.buttons === 1
: event.buttons === 1 &&
event.target instanceof Element &&
!!event.target.closest('[data-weather-interactive="brush"]');
if (kind === 'move' &&
brush.enabled &&
intentional &&
!event.shiftKey &&
Math.hypot(vx, vy) > 20) {
let brushed = 0;
const start = { x: previous?.x ?? event.clientX, y: previous?.y ?? event.clientY };
const end = { x: event.clientX, y: event.clientY };
const dx = end.x - start.x, dy = end.y - start.y, lengthSquared = dx * dx + dy * dy;
const nearest = (point) => {
const t = lengthSquared > 0
? Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared))
: 1;
return { x: start.x + t * dx, y: start.y + t * dy };
};
// Cursor airflow affects nearby falling flakes without capturing a click or storing history.
for (const flake of this.particles.particles) {
if (flake.kind !== 'flake')
continue;
const point = nearest(flake), fx = flake.x - point.x, fy = flake.y - point.y, distance = Math.hypot(fx, fy);
const radius = brush.radius * 1.5;
if (distance > 1 && distance < radius) {
const impulse = (1 - distance / radius) * Math.min(100, Math.sqrt(lengthSquared) * 3) * brush.strength;
flake.vx = Math.max(-500, Math.min(500, flake.vx + (fx / distance) * impulse + vx * 0.025));
flake.vy = Math.max(-200, Math.min(400, flake.vy + (fy / distance) * impulse + vy * 0.012));
}
}
const random = seededRandom((Math.floor(now * 1000000) ^ this.config.seed) >>> 0);
for (const s of this.registry?.surfaces ?? []) {
if (!s.visible || s.occluded)
continue;
for (let bin = 0; bin < s.profile.length; bin++) {
const before = s.profile[bin] ?? 0;
if (before <= 0.01 || s.exposed?.[bin] === 0)
continue;
const x = (bin + 0.5) * s.binSize, depth = depthAt(s, x);
const center = localToWorld(s, x, -depth * 0.5), point = nearest(center);
const distance = Math.hypot(center.x - point.x, center.y - point.y);
if (distance >= brush.radius)
continue;
const strength = distance < brush.radius * 0.6
? Math.min(1, brush.strength * 1.5)
: Math.min(1, (1 - distance / brush.radius) * brush.strength * 2);
s.profile[bin] = before * (1 - strength);
const mass = before - (s.profile[bin] ?? 0);
if (mass <= 0)
continue;
brushed += mass;
const count = Math.min(12, Math.max(1, Math.ceil(mass / 10)));
for (let i = 0; i < count; i++) {
const pos = localToWorld(s, x + (random() - 0.5) * s.binSize, -depth * 0.5);
this.particles.spawnClump(pos.x, pos.y, mass / count, Math.max(-450, Math.min(450, vx * 0.3)) + (random() - 0.5) * 65, Math.max(35, depth / 0.32, vy * 0.25 + 25) + random() * 30, s);
}
}
}
if (brushed && now - this.lastBrush > 0.08) {
this.emit('snow:brush', { mass: brushed });
this.lastBrush = now;
}
}
if (this.config.interactions.pointerVortex.enabled && event.shiftKey)
for (const p of this.particles.particles) {
const dx = p.x - event.clientX, dy = p.y - event.clientY, d = Math.hypot(dx, dy);
if (d > 4 && d < 120) {
p.vx += (-dy / d) * Math.min(10, Math.hypot(vx, vy) * 0.01);
p.vy += (dx / d) * Math.min(10, Math.hypot(vx, vy) * 0.01);
}
}
this.pointer = { x: event.clientX, y: event.clientY, time: now };
}
cancelGathering() {
this.actors?.pointer({
x: this.pointer?.x ?? 0,
y: this.pointer?.y ?? 0,
vx: 0,
vy: 0,
pressed: false,
shiftKey: false,
kind: 'cancel',
time: this.time,
});
this.pointer = undefined;
this.gatheringPointer = undefined;
}
syncControls() {
const doc = this.root?.ownerDocument;
if (!this.config.accessibility.controls || !doc) {
this.toggle?.remove();
this.toggle = undefined;
return;
}
if (!this.toggle) {
const button = doc.createElement('button');
button.type = 'button';
button.dataset.fluriRoot = 'control';
Object.assign(button.style, {
position: 'fixed',
right: '16px',
bottom: '16px',
zIndex: '2147483001',
border: '1px solid #758396',
borderRadius: '999px',
padding: '10px 16px',
background: '#111b2d',
color: '#f3f7ff',
font: '500 14px system-ui',
cursor: 'pointer',
});
button.addEventListener('click', () => {
if (this.state === 'running')
this.pause();
else
this.resume();
});
doc.body.append(button);
this.toggle = button;
}
this.toggle.textContent = this.state === 'running' ? 'Pause snow' : 'Resume snow';
this.toggle.setAttribute('aria-pressed', String(this.state === 'running'));
}
}
//# sourceMappingURL=engine.js.map