@fluritheme/fluri
Version:
Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.
165 lines • 7.78 kB
JavaScript
import { clamp } from './math.js';
const depositScratch = new WeakMap();
export function totalMass(profile) {
let mass = 0;
for (const value of profile)
mass += value;
return mass;
}
/** Interpolate bin centres so the collision edge matches the rendered silhouette. */
export function depthAt(surface, x) {
if (!surface.profile.length || surface.binSize <= 0)
return 0;
const position = clamp(x / surface.binSize - 0.5, 0, surface.profile.length - 1);
const left = Math.floor(position);
const right = Math.min(left + 1, surface.profile.length - 1);
const amount = position - left;
const a = (surface.profile[left] ?? 0) / surface.binSize;
const b = (surface.profile[right] ?? 0) / surface.binSize;
const difference = b - a;
const before = a - (surface.profile[Math.max(0, left - 1)] ?? 0) / surface.binSize;
const after = (surface.profile[Math.min(surface.profile.length - 1, right + 1)] ?? 0) / surface.binSize - b;
const tangent = (first, second) => first * second > 0 ? (2 * first * second) / (first + second) : 0;
const t2 = amount * amount;
const t3 = t2 * amount;
// Shape-preserving cubic interpolation rounds real hills, never inventing periodic ripples.
let depth = (2 * t3 - 3 * t2 + 1) * a +
(t3 - 2 * t2 + amount) * tangent(before, difference) +
(-2 * t3 + 3 * t2) * b +
(t3 - t2) * tangent(difference, after);
const corner = Math.min(Math.max(0, surface.radius ?? 0), surface.width / 2);
if (corner > 0) {
const start = Math.min(corner * 0.32, 6);
const distance = Math.min(x, surface.width - x);
const t = clamp((distance - start) / Math.max(0.001, corner - start), 0, 1);
// Elliptical shoulder has a rounded end, unlike a long triangular smoothstep ramp.
depth *= Math.sqrt(Math.max(0, 1 - (1 - t) * (1 - t)));
}
return Math.max(0, depth);
}
/** Return accepted mass; callers must keep or shed any surplus instead of losing it. */
export function deposit(profile, binSize, x, mass, maxDepth = Infinity, packing) {
if (!profile.length || binSize <= 0 || !(mass > 0) || !Number.isFinite(mass))
return 0;
const maximum = Math.max(0, maxDepth) * binSize;
const capacityAt = (index) => maximum * (packing?.[index] ?? 1);
let remaining = mass;
let accepted = 0;
const centre = clamp(x, 0, profile.length * binSize);
let spread = Math.max(binSize * 0.72, Math.sqrt(mass) * 0.55);
let weights = depositScratch.get(profile);
if (!weights) {
weights = new Float64Array(profile.length);
depositScratch.set(profile, weights);
}
// Most flakes affect two or three bins. Large impacts expand only after local capacity fills.
for (let pass = 0; pass < 16 && remaining > 1e-6; pass++) {
const first = Math.max(0, Math.floor((centre - spread * 2.6) / binSize));
const last = Math.min(profile.length - 1, Math.floor((centre + spread * 2.6) / binSize));
let totalWeight = 0;
weights.fill(0, first, last + 1);
for (let index = first; index <= last; index++) {
if ((profile[index] ?? 0) >= capacityAt(index) - 1e-7)
continue;
const offset = ((index + 0.5) * binSize - centre) / spread;
const weight = Math.exp(-0.5 * offset * offset);
weights[index] = weight;
totalWeight += weight;
}
if (totalWeight > 0) {
const available = remaining;
for (let index = first; index <= last; index++) {
const weight = weights[index] ?? 0;
if (!weight)
continue;
const previous = profile[index] ?? 0;
const requested = Math.min(remaining, (available * weight) / totalWeight, Math.max(0, capacityAt(index) - previous));
profile[index] = previous + requested;
const actual = (profile[index] ?? 0) - previous;
accepted += actual;
remaining = Math.max(0, remaining - actual);
}
}
spread = Math.min(profile.length * binSize * 2, spread * 1.8);
}
return accepted;
}
/** Remove mass within an edge-local brush radius, nearest bins first. */
export function remove(profile, binSize, x, radius, mass = Infinity) {
if (!profile.length || binSize <= 0 || radius < 0 || !(mass > 0))
return 0;
const first = Math.max(0, Math.floor((x - radius) / binSize));
const last = Math.min(profile.length - 1, Math.floor((x + radius) / binSize));
const indices = [];
for (let index = first; index <= last; index++)
indices.push(index);
indices.sort((a, b) => Math.abs((a + 0.5) * binSize - x) - Math.abs((b + 0.5) * binSize - x));
let removed = 0;
for (const index of indices) {
const previous = profile[index] ?? 0;
profile[index] = Math.max(0, previous - Math.min(previous, mass - removed));
removed += previous - (profile[index] ?? 0);
if (removed >= mass)
break;
}
return removed;
}
/** Pairwise diffusion conserves area and cannot overshoot even for isolated peaks. */
export function smooth(profile, strength, iterations = 1) {
const amount = clamp(strength, 0, 1) * 0.25;
if (amount === 0 || profile.length < 2)
return;
const boundedIterations = Math.min(32, Math.max(0, Math.floor(iterations)));
for (let iteration = 0; iteration < boundedIterations; iteration++) {
for (let parity = 0; parity < 2; parity++) {
for (let index = parity; index + 1 < profile.length; index += 2) {
const left = profile[index] ?? 0;
const right = profile[index + 1] ?? 0;
const transfer = (left - right) * amount;
profile[index] = left - transfer;
profile[index + 1] = right + transfer;
}
}
}
}
/** Remap bins on responsive width changes without changing the deposit's mass. */
export function resizeProfile(profile, length) {
const next = new Float32Array(Math.max(1, Math.floor(length)));
if (!profile.length)
return next;
const scale = next.length / profile.length;
for (let index = 0; index < profile.length; index++) {
const start = index * scale;
const end = (index + 1) * scale;
const mass = profile[index] ?? 0;
for (let destination = Math.floor(start); destination < Math.ceil(end); destination++) {
const overlap = Math.max(0, Math.min(end, destination + 1) - Math.max(start, destination));
if (destination >= 0 && destination < next.length)
next[destination] = (next[destination] ?? 0) + (mass * overlap) / scale;
}
}
return next;
}
/** Explicit melting is an intentional mass sink, reported by its return value. */
export function melt(profile, binSize, rate, dt) {
let removed = 0;
const amount = Math.max(0, rate * dt * binSize);
for (let index = 0; index < profile.length; index++) {
const previous = profile[index] ?? 0;
profile[index] = Math.max(0, previous - amount);
removed += previous - (profile[index] ?? 0);
}
return removed;
}
/** Actual rounded card shoulder below the horizontal top-edge datum. */
export function supportYAt(surface, x) {
const r = Math.min(Math.max(0, surface.radius), surface.width / 2);
if (r === 0)
return 0;
const distance = clamp(Math.min(x, surface.width - x), 0, r);
return r - Math.sqrt(Math.max(0, r * r - (r - distance) * (r - distance)));
}
export function snowYAt(surface, x) {
return supportYAt(surface, x) - depthAt(surface, x);
}
//# sourceMappingURL=profiles.js.map