@fluritheme/fluri
Version:
Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.
415 lines • 17 kB
JavaScript
import { SnowTexture } from './Texture.js';
import { buildSnowContours } from './SnowContour.js';
import { SnowContrast } from './Contrast.js';
const layers = ['background', 'content', 'foreground'];
export class CanvasRenderer {
doc;
scope;
host;
canvases = new Map();
contexts = new Map();
width = 0;
height = 0;
dpr = 1;
bitmapWidth = 0;
bitmapHeight = 0;
mountedScope;
mountDirty = true;
scopeEligible = false;
maxDPR = 1;
ignored = [];
lastIgnoreRefresh = -Infinity;
texture;
allowGlow = true;
contactShadows = true;
contrast;
paleSnow = true;
contourCache = new WeakMap();
constructor(doc, onTextureError, onAppearanceChange = () => { }, scope) {
this.doc = doc;
this.scope = scope;
this.texture = new SnowTexture(doc, onTextureError);
this.contrast = new SnowContrast(doc, () => {
this.mountDirty = true;
onAppearanceChange();
});
this.host = doc.createElement('div');
this.host.dataset.fluriRoot = 'effects';
this.host.dataset.fluriMount = 'viewport';
this.host.setAttribute('aria-hidden', 'true');
this.host.inert = true;
Object.assign(this.host.style, {
position: 'fixed',
inset: '0',
pointerEvents: 'none',
zIndex: '2147483000',
overflow: 'hidden',
contain: 'strict',
});
for (const layer of layers) {
const canvas = doc.createElement('canvas');
canvas.dataset.fluriLayer = layer;
canvas.setAttribute('aria-hidden', 'true');
Object.assign(canvas.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
});
const context = canvas.getContext('2d', { alpha: true });
if (!context)
throw new Error('Fluri needs Canvas 2D support');
this.host.append(canvas);
this.canvases.set(layer, canvas);
this.contexts.set(layer, context);
}
doc.body.append(this.host);
}
get textureStatus() {
return this.texture.status;
}
resize(maxDPR) {
this.maxDPR = maxDPR;
const win = this.doc.defaultView;
if (!win)
return;
const width = win.innerWidth, height = win.innerHeight, dpr = Math.min(win.devicePixelRatio || 1, maxDPR);
const scope = this.scope;
if (this.mountDirty && scope) {
this.scopeEligible =
scope !== this.doc.body && scope !== this.doc.documentElement && scope.isConnected;
let ancestor = scope;
while (this.scopeEligible && ancestor) {
const style = win.getComputedStyle(ancestor);
if ((ancestor === scope &&
(style.position === 'static' ||
style.display === 'contents' ||
style.display === 'inline')) ||
style.transform !== 'none' ||
style.perspective !== 'none' ||
(style.getPropertyValue('zoom') &&
!['1', 'normal'].includes(style.getPropertyValue('zoom'))))
this.scopeEligible = false;
ancestor = ancestor.parentElement;
}
this.mountDirty = false;
}
const scopeRect = this.scopeEligible ? scope?.getBoundingClientRect() : undefined;
const nativeScope = this.scopeEligible &&
scope &&
scopeRect &&
scopeRect.width > 0 &&
scopeRect.height > 0 &&
scopeRect.width <= width * 1.5 &&
scopeRect.height <= height * 1.5 &&
scope.scrollHeight <= scope.clientHeight + 1 &&
scope.scrollWidth <= scope.clientWidth + 1
? scope
: undefined;
if (nativeScope !== this.mountedScope) {
this.mountedScope = nativeScope;
this.host.style.position = nativeScope ? 'absolute' : 'fixed';
this.host.dataset.fluriMount = nativeScope ? 'scope' : 'viewport';
(nativeScope ?? this.doc.body).append(this.host);
}
const bounds = nativeScope ? this.host.getBoundingClientRect() : undefined;
const bitmapWidth = bounds?.width ?? width;
const bitmapHeight = bounds?.height ?? height;
if (width === this.width &&
height === this.height &&
dpr === this.dpr &&
bitmapWidth === this.bitmapWidth &&
bitmapHeight === this.bitmapHeight)
return;
this.width = width;
this.height = height;
this.bitmapWidth = bitmapWidth;
this.bitmapHeight = bitmapHeight;
this.dpr = dpr;
for (const [layer, canvas] of this.canvases) {
canvas.width = Math.round(bitmapWidth * dpr);
canvas.height = Math.round(bitmapHeight * dpr);
this.contexts.get(layer)?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
}
clear() {
for (const ctx of this.contexts.values())
ctx.clearRect(0, 0, this.bitmapWidth, this.bitmapHeight);
}
render(particles, surfaces, config, time, plugins, actorDraw) {
// Paused previews still receive layout/theme invalidations and must keep their backing store sized.
this.resize(this.maxDPR);
this.clear();
this.contrast.refresh(surfaces, this.width, this.height);
this.paleSnow = this.contrast.isPale(config.snow.color);
this.allowGlow = particles.length < 240;
this.contactShadows = surfaces.length < 40;
this.texture.setSource(config.snow.texture ?? undefined);
if (time - this.lastIgnoreRefresh > 0.2 || this.lastIgnoreRefresh === -Infinity) {
this.ignored = [
...this.doc.querySelectorAll(['[data-weather-ignore]', ...config.dom.exclude].join(',')),
].slice(0, 512);
this.lastIgnoreRefresh = time;
}
const ignoredRects = this.ignored
.filter((el) => el.isConnected)
.map((el) => el.getBoundingClientRect())
.filter((r) => r.width > 0 && r.height > 0);
const sorted = surfaces
.filter((s) => s.visible && !s.occluded)
.sort((a, b) => a.paintOrder - b.paintOrder);
const origin = this.mountedScope ? this.host.getBoundingClientRect() : undefined;
for (const layer of layers) {
const ctx = this.contexts.get(layer);
if (!ctx)
continue;
ctx.save();
if (origin)
ctx.translate(-origin.left, -origin.top);
if (config.container &&
config.container !== this.doc.body &&
config.container !== this.doc.documentElement) {
const r = config.container.getBoundingClientRect();
ctx.beginPath();
ctx.rect(r.left, r.top, r.width, r.height);
ctx.clip();
}
for (const p of particles)
if (p.layer === layer)
this.particle(ctx, p, config);
if (layer === 'content') {
// Mask flakes before attached deposits so their lower edge can meet the actual rounded shoulder.
if (config.dom.respectLayers)
this.maskBodies(ctx, sorted);
for (const s of sorted)
if (s.layer === 'content')
this.snow(ctx, s, config);
if (config.dom.respectLayers)
this.maskBodies(ctx, sorted, true);
actorDraw(ctx, time);
for (const plugin of plugins) {
ctx.save();
plugin.render?.(ctx, time);
ctx.restore();
}
}
else {
for (const s of sorted)
if (s.layer === layer)
this.snow(ctx, s, config);
if (layer === 'background' && config.dom.respectLayers)
this.maskBodies(ctx, sorted);
}
if (config.debug && layer === 'foreground')
this.debug(ctx, sorted);
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = '#000';
for (const r of ignoredRects)
ctx.fillRect(r.left, r.top, r.width, r.height);
ctx.restore();
ctx.restore();
}
}
particle(ctx, p, config) {
const contrast = this.paleSnow && p.kind !== 'sparkle' ? this.contrast.at(p.x, p.y) : 0;
ctx.globalAlpha = Math.min(1, Math.max(0, p.opacity));
ctx.fillStyle = p.kind === 'sparkle' ? '#ffd7a1' : config.snow.color;
if (this.allowGlow && config.snow.glow && p.radius > 1.8) {
ctx.shadowColor = ctx.fillStyle;
ctx.shadowBlur = Math.min(7, p.radius * 2);
}
if (this.texture.image && p.kind === 'flake') {
ctx.drawImage(this.texture.image, p.x - p.radius, p.y - p.radius, p.radius * 2, p.radius * 2);
}
else if (config.snow.flakeStyle === 'crystal' && p.kind === 'flake') {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.phase + p.age * 0.2);
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const a = (i * Math.PI) / 3;
ctx.moveTo(-Math.cos(a) * p.radius * 1.5, -Math.sin(a) * p.radius * 1.5);
ctx.lineTo(Math.cos(a) * p.radius * 1.5, Math.sin(a) * p.radius * 1.5);
}
if (contrast > 0.05) {
ctx.strokeStyle = `rgba(89,120,143,${0.72 * contrast})`;
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.strokeStyle = config.snow.color;
ctx.lineWidth = 0.8;
ctx.stroke();
ctx.restore();
}
else if (p.kind === 'clump' && p.radius > 1.4) {
ctx.beginPath();
for (let index = 0; index < 12; index++) {
const a = (index / 12) * Math.PI * 2;
const r = p.radius *
(0.77 +
0.11 * Math.sin(p.phase + index * 2.37) +
0.08 * Math.cos(index * 1.13 + p.phase));
const x = p.x + Math.cos(a) * r, y = p.y + Math.sin(a) * r * 0.72;
if (index === 0)
ctx.moveTo(x, y);
else
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
if (contrast > 0.05) {
ctx.strokeStyle = `rgba(89,120,143,${0.8 * contrast})`;
ctx.lineWidth = 0.85;
ctx.stroke();
}
}
else {
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.radius, p.kind === 'clump' ? p.radius * 0.8 : p.radius, 0, 0, Math.PI * 2);
ctx.fill();
if (contrast > 0.05) {
ctx.strokeStyle = `rgba(89,120,143,${0.8 * contrast})`;
ctx.lineWidth = 0.85;
ctx.stroke();
}
}
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
snow(ctx, s, config) {
if (!config.snow.accumulation.enabled || !s.accumulation)
return;
let signature = s.occluded ? 1 : 0;
for (let i = 0; i < s.profile.length; i++)
signature = Math.imul(signature ^ Math.round((s.profile[i] ?? 0) * 128) ^ (s.exposed?.[i] ?? 1), 16777619);
let cached = this.contourCache.get(s);
if (!cached ||
cached.profile !== s.profile ||
cached.signature !== signature ||
cached.width !== s.width ||
cached.radius !== s.radius) {
cached = {
profile: s.profile,
signature,
width: s.width,
radius: s.radius,
contours: buildSnowContours(s),
};
this.contourCache.set(s, cached);
}
const contours = cached.contours;
if (!contours.length)
return;
ctx.save();
if (s.options.clip !== false)
for (const c of s.clipRegions) {
ctx.beginPath();
ctx.roundRect(c.x, c.y, c.width, c.height, c.radius);
ctx.clip();
}
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
const contrast = this.paleSnow ? this.contrast.forSurface(s) : 0;
for (const contour of contours) {
const first = contour.points[0], last = contour.points.at(-1);
if (!first || !last)
continue;
// The contour comes from stored mass, while a small contact shadow gives the deposit volume.
const gradient = ctx.createLinearGradient(0, -contour.peak, 0, 0.5);
gradient.addColorStop(0, this.paleSnow ? '#ffffff' : config.snow.color);
gradient.addColorStop(0.45, config.snow.color);
gradient.addColorStop(1, this.paleSnow ? (contrast > 0.05 ? '#b0c4d4' : '#d1deea') : config.snow.color);
ctx.fillStyle = gradient;
ctx.shadowColor = 'rgba(5,16,32,.2)';
ctx.shadowBlur = this.contactShadows ? 1 : 0;
ctx.shadowOffsetY = 1;
ctx.beginPath();
ctx.moveTo(first.x, first.base + 0.35);
for (const point of contour.points)
ctx.lineTo(point.x, point.base - point.depth);
for (let index = contour.points.length - 1; index >= 0; index--) {
const point = contour.points[index];
if (point)
ctx.lineTo(point.x, point.base + 0.35);
}
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Restrained rim light follows the same physical edge, rather than a scalloped white stripe.
if (contour.peak > 1.5) {
ctx.strokeStyle =
contrast > 0.05 ? `rgba(89,120,143,${0.72 * contrast})` : 'rgba(255,255,255,.35)';
ctx.lineWidth = contrast > 0.05 ? 0.95 : 0.5;
ctx.beginPath();
contour.points.forEach((point, index) => {
if (index === 0)
ctx.moveTo(point.x, point.base - point.depth + 0.25);
else
ctx.lineTo(point.x, point.base - point.depth + 0.25);
});
ctx.stroke();
}
}
ctx.restore();
}
maskBodies(ctx, surfaces, deposits = false) {
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = '#000';
for (const s of surfaces) {
if (s.opaque === false)
continue;
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
ctx.beginPath();
const top = deposits ? Math.min(6, Math.max(1.25, s.radius * 0.35)) : 1;
ctx.roundRect(0, top, s.width, Math.max(0, s.height - top), s.radius);
ctx.fill();
ctx.restore();
}
ctx.restore();
}
debug(ctx, surfaces) {
ctx.font = '10px ui-monospace,monospace';
ctx.lineWidth = 1;
for (const s of surfaces) {
ctx.save();
ctx.strokeStyle = '#b49df5';
ctx.setLineDash([3, 4]);
for (const clip of s.clipRegions) {
ctx.beginPath();
ctx.roundRect(clip.x, clip.y, clip.width, clip.height, clip.radius);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
ctx.strokeStyle = s.confidence === 'low' ? '#ffbe86' : '#68f1dc';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(s.width, 0);
const middle = s.width / 2;
ctx.moveTo(middle, 0);
ctx.lineTo(middle, -18);
ctx.moveTo(middle - 3, -13);
ctx.lineTo(middle, -18);
ctx.lineTo(middle + 3, -13);
ctx.stroke();
ctx.fillStyle = '#68f1dc';
ctx.fillText(`${s.id} · ${s.layer} · paint ${s.paintOrder} · ${s.confidence}`, 4, -s.maxDepth - 7);
ctx.restore();
}
}
destroy() {
this.contrast.destroy();
this.texture.destroy();
this.host.remove();
this.canvases.clear();
this.contexts.clear();
}
}
//# sourceMappingURL=CanvasRenderer.js.map