dssl / dssl-vscode /lib /engine /aura.js
EHS-Studio's picture
Claude Sonnet 5.5
v0.16.0: round three
10656db
Raw History Blame Contribute Delete
69.3 kB
/* =========================================================================
* DSSL: aura.js
*
* .orb { aura-glow: color.accent 28 pulse 1600 }
* .orb { aura-particle: #7cf -> transparent count 40 dir up from bottom }
* .orb { aura: glow #ff7a00 30 flicker 0.3, particles #fc4 -> #f30 count 60 shape flame glowing }
*
* An aura is light and particles around an element. It has NO presets: fire,
* an energy aura, snow and sparks are all just parameters, and you name your
* own with a `behavior` block. What you get is the set of knobs.
*
* Two kinds of layer:
* glow a soft light around the element (color, radius, pulse, flicker)
* particles things that leave the element (count, size, life, speed, dir ...)
*
* Properties (all of them end up in the same two slots, so they stack with the cascade):
* aura: glow and/or particles, comma separated. Replaces both slots.
* aura-glow: the glow slot
* aura-particle(s): the particles slot
* glow-particle(s): the particles slot, every particle glowing
* aura-glow-particle(s): both slots at once; settings are shared
*
* This file has two halves. The top is PURE (parseAura, no DOM) and runs in the
* compiler. The bottom is the canvas RUNTIME (AuraManager) that the engine drives.
* ========================================================================= */
import { parseColor, toRgba, withAlpha } from './color.js';
import { parseTexture, makeMask, splitWords, rng } from './texture.js';
/* ------------------------------------------------------------------ *
* vocabulary
* ------------------------------------------------------------------ */
export const FROM_WORDS = ['edge', 'outline', 'bottom', 'top', 'left', 'right', 'center', 'corners', 'thumb'];
export const SHAPES = ['circle', 'spark', 'square', 'ring', 'star', 'flame', 'smoke', 'line',
'heart', 'diamond', 'triangle', 'plus', 'drop', 'bubble', 'snowflake', 'confetti', 'leaf']; // 0.16.0: the last nine
/* ---- shapes an aura can follow (0.16.0) ---- */
const TWO_PI = Math.PI * 2;
const ngon = (n, rot = -Math.PI / 2) => Array.from({ length: n }, (_, i) => { const a = rot + (i / n) * TWO_PI; return [0.5 + 0.5 * Math.cos(a), 0.5 + 0.5 * Math.sin(a)]; });
const starOf = (n, inner) => Array.from({ length: n * 2 }, (_, i) => { const a = -Math.PI / 2 + (i / (n * 2)) * TWO_PI, r = i % 2 ? inner : 1; return [0.5 + 0.5 * r * Math.cos(a), 0.5 + 0.5 * r * Math.sin(a)]; });
const heartOf = () => {
const pts = [];
for (let i = 0; i < 48; i++) { const t = (i / 48) * TWO_PI; pts.push([16 * Math.pow(Math.sin(t), 3), -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t))]); }
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
return pts.map(([x, y]) => [(x - x0) / (x1 - x0), (y - y0) / (y1 - y0)]);
};
const dropOf = () => {
const pts = [[0.5, 0]];
for (let deg = -32.6; deg <= 212.6; deg += 8) { const a = (deg * Math.PI) / 180; pts.push([0.5 + 0.35 * Math.cos(a), 0.65 + 0.35 * Math.sin(a)]); }
return pts;
};
/** the polygons, as fractions of the element's box (0..1, y down) */
const UNIT_SHAPES = {
diamond: [[0.5, 0], [1, 0.5], [0.5, 1], [0, 0.5]],
triangle: [[0.5, 0], [1, 1], [0, 1]],
hexagon: ngon(6, 0),
octagon: ngon(8, Math.PI / 8),
pentagon: ngon(5),
star: starOf(5, 0.4),
star4: starOf(4, 0.36),
star6: starOf(6, 0.5),
cross: [[0.33, 0], [0.67, 0], [0.67, 0.33], [1, 0.33], [1, 0.67], [0.67, 0.67], [0.67, 1], [0.33, 1], [0.33, 0.67], [0, 0.67], [0, 0.33], [0.33, 0.33]],
heart: heartOf(),
drop: dropOf(),
bolt: [[0.58, 0], [0.18, 0.56], [0.46, 0.56], [0.34, 1], [0.82, 0.38], [0.54, 0.38], [0.72, 0]],
};
/** the names `around` takes (polygon(...) is the other form) */
export const AROUND_NAMES = ['follow', 'box', 'round', 'pill', 'alpha', ...Object.keys(UNIT_SHAPES)];
/** `around` in its parsed form to a polygon, as [x, y] fractions of the box, or null for a shape that is not a polygon (box, round, pill, follow, alpha) */
export function aroundPoints(spec) {
if (!spec) return null;
if (spec.name === 'polygon') return spec.pts;
return UNIT_SHAPES[spec.name] || null;
}
/** `around` words to {name} or {name:'polygon', pts}; null with a message when it is neither */
export function parseAround(word, hooks) {
const w = String(word || '').trim();
if (AROUND_NAMES.includes(w)) return { name: w };
const m = /^polygon\((.*)\)$/s.exec(w);
if (!m) { hooks.error(`unknown around "${w}"`, hooks.suggest(w, AROUND_NAMES) || `use one of: ${AROUND_NAMES.join(', ')}, or polygon(50% 0, 100% 50%, 50% 100%, 0 50%)`); return null; }
const pts = [];
for (const part of m[1].split(',')) {
const t = part.trim().split(/\s+/);
const v = t.map((x) => (/^-?[\d.]+%$/.test(x) ? parseFloat(x) / 100 : /^-?[\d.]+$/.test(x) ? parseFloat(x) / 100 : NaN));
if (t.length !== 2 || v.some((n) => !Number.isFinite(n))) { hooks.error(`polygon needs "x y" pairs in percent, got "${part.trim()}"`, 'around polygon(50% 0, 100% 50%, 50% 100%, 0 50%)'); return null; }
pts.push(v);
}
if (pts.length < 3) { hooks.error('a polygon needs at least 3 points', 'around polygon(50% 0, 100% 100%, 0 100%)'); return null; }
return { name: 'polygon', pts };
}
export const DIR_WORDS = { up: -90, down: 90, left: 180, right: 0, out: 'out', in: 'in' };
export const AURA_BLENDS = ['add', 'normal'];
const num = (min, max, extra = {}) => ({ kind: 'number', words: 1, min, max, ...extra });
export const GLOW_KEYS = {
color: { kind: 'color', words: 1, doc: 'the light colour (default: the element\'s text colour)' },
radius: num(1, 400, { doc: 'how far the glow reaches, in px' }),
intensity: num(0, 4, { doc: 'brightness, 1 is normal' }),
pulse: num(0, 60000, { doc: 'milliseconds for one breath in and out; 0 is steady' }),
depth: num(0, 1, { doc: 'how deep the pulse goes, 0 to 1' }),
flicker: num(0, 1, { doc: 'random flicker, like a flame: 0 is steady, 1 is wild' }),
seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
unclipped: { kind: 'flag', words: 0, doc: 'let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself' },
around: { kind: 'around', words: 1, doc: 'the shape the light follows: follow (the default: the element\'s clip-path or rounded corners), box, round, pill, diamond, triangle, hexagon, octagon, pentagon, star, star4, star6, cross, heart, drop, bolt, alpha (an <img> or <canvas>\'s visible pixels), or polygon(50% 0, 100% 50%, 50% 100%, 0 50%)' },
inner: { kind: 'flag', words: 0, doc: 'light inside the edge (a rim) instead of outside it' },
rays: num(1, 64, { int: true, doc: 'that many beams of light from the middle, slowly turning (see spin)' }),
spin: num(-3600, 3600, { doc: 'rays turn this many degrees a second (default 10); on particles, how fast each one tumbles' }),
ripple: num(200, 60000, { doc: 'rings that spread out from the shape and fade, one every this many milliseconds' }),
nohalo: { kind: 'flag', words: 0, doc: 'only the rays and ripples, without the soft halo' },
always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' },
z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer this light is drawn on: `z 5` puts it under a hill with z-index 10. It meets elements in the page\'s root stacking context' }),
};
export const PARTICLE_KEYS = {
color: { kind: 'colors', words: 1, doc: 'colours over a particle\'s life: color #ff0 -> #f60 -> transparent' },
count: num(1, 600, { int: true, doc: 'how many particles are alive at once' }),
size: { kind: 'range', words: 2, min: 0.5, max: 300, doc: 'diameter in px: size 4, or size 10 4 (start and end of a random range)' },
life: { kind: 'range', words: 2, min: 50, max: 60000, doc: 'how long one lives, in ms: life 900, or life 600 1200' },
speed: { kind: 'range', words: 2, min: 0, max: 3000, doc: 'px per second: speed 60, or speed 30 90' },
dir: { kind: 'dir', words: 1, doc: 'where they head: up, down, left, right, out, in, or degrees (-90 is up)' },
spread: num(0, 360, { doc: 'how wide the fan of directions is, in degrees' }),
gravity: num(-2000, 2000, { doc: 'px/s squared; negative pulls up (flames), positive down (snow)' }),
drag: num(0, 10, { doc: 'air resistance: how fast particles slow down' }),
turbulence: num(0, 1000, { doc: 'random sideways wobble, px/s' }),
from: { kind: 'word', words: 1, values: FROM_WORDS, doc: 'where they are born: edge or outline (along the element\'s real outline, see around), bottom, top, left, right, center, corners, thumb (range sliders)' },
shape: { kind: 'word', words: 1, values: SHAPES, doc: 'circle, spark, square, ring, star, flame, smoke, line, heart, diamond, triangle, plus, drop, bubble, snowflake, confetti or leaf' },
grow: num(0, 10, { doc: 'size at the end of life as a multiple of the start: 0.2 shrinks, 3 swells' }),
blend: { kind: 'word', words: 1, values: AURA_BLENDS, doc: 'add makes overlapping particles brighter, normal just stacks them' },
glowing: { kind: 'flag', words: 0, doc: 'soft glowing particles that add light' },
behind: { kind: 'flag', words: 0, doc: 'draw only outside the element\'s box, as if the element hid them' },
seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
unclipped: { kind: 'flag', words: 0, doc: 'let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge' },
around: { kind: 'around', words: 1, doc: 'the shape `from edge` and `from outline` follow: follow (the default), box, round, pill, diamond, triangle, hexagon, octagon, pentagon, star, star4, star6, cross, heart, drop, bolt, or polygon(...)' },
palette: { kind: 'flag', words: 0, doc: 'each particle takes ONE of the colours (confetti) instead of fading through them' },
spin: num(-3600, 3600, { doc: 'how fast each particle tumbles, in degrees a second (random direction)' }),
wind: num(-3000, 3000, { doc: 'a sideways push, px/s squared; negative pushes left' }),
orbit: num(-3600, 3600, { doc: 'the particles circle the middle of the element, this many degrees a second' }),
always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' },
z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer these particles are drawn on: `z 5` puts them under a hill with z-index 10. It meets elements in the page\'s root stacking context' }),
};
/* ------------------------------------------------------------------ *
* parsing (compile time, no DOM)
* ------------------------------------------------------------------ */
function splitCommas(s) {
const out = []; let depth = 0, cur = '';
for (const ch of s) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}
const isNumeric = (w) => w !== '' && !Number.isNaN(Number(w));
/** could this word be a colour? A bare unknown word (speedd) must NOT be swallowed as one, or the typo
* never gets its "did you mean" */
const looksLikeColor = (w) => /^#[0-9a-f]{3,8}(\/[\d.]+%?)?$/i.test(w) || /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|var)\(/i.test(w)
|| /^[a-z][\w-]*(\.[\w-]+)+(\/[\d.]+%?)?$/i.test(w) || /^(transparent|currentcolor)$/i.test(w) || parseColor(w) !== null;
/**
* Parse one layer's words.
* kind: 'glow' | 'particles' | 'both' which keys are legal
* forceGlowing: glow-particle
* Returns { glow?, particles? } layers, or null (an error was reported).
*/
function parseLayerWords(words, kind, hooks, opts = {}) {
const wantGlow = kind === 'glow' || kind === 'both';
const wantParticles = kind === 'particles' || kind === 'both';
const keys = kind === 'glow' ? GLOW_KEYS : kind === 'particles' ? PARTICLE_KEYS : { ...GLOW_KEYS, ...PARTICLE_KEYS };
const g = {}, p = {};
const setBoth = (k, v) => { if (k in GLOW_KEYS && wantGlow) g[k] = v; if (k in PARTICLE_KEYS && wantParticles) p[k] = v; };
const textureOf = (inner) => parseTexture(inner, hooks);
let i = 0;
const err = (m, h) => { hooks.error(m, h); return null; };
// positional shorthand: aura-glow: <color> <radius> aura-particle: <colors> <count>
const isKey = (w) => Object.prototype.hasOwnProperty.call(keys, w) || w in DIR_WORDS;
if (words.length && !isKey(words[0]) && words[0] !== '->') {
const stops = [];
while (i < words.length && !isKey(words[i]) && !isNumeric(words[i]) && (words[i] === '->' || looksLikeColor(words[i]))) {
if (words[i] === '->') { i++; continue; }
const css = hooks.color(words[i]);
if (css === null) return null;
stops.push(css);
i++;
}
if (stops.length) {
if (wantGlow) g.color = stops[0];
if (wantParticles) p.colors = stops;
}
if (i < words.length && isNumeric(words[i])) {
const n = Number(words[i]);
if (wantGlow && !wantParticles) g.radius = n;
else if (wantParticles && !wantGlow) p.count = n;
else { g.radius = n; }
i++;
}
}
while (i < words.length) {
const w = words[i];
if (w in DIR_WORDS && !(w in keys)) { if (wantParticles) p.dir = DIR_WORDS[w]; i++; continue; }
// texture(grain scale 2) is one function-style word that carries its own settings
const fnTex = /^texture\((.*)\)$/s.exec(w);
if (fnTex && keys.texture) {
i++;
const t = textureOf(fnTex[1].trim());
if (t === null) return null;
setBoth('texture', t);
continue;
}
const def = keys[w];
if (!def) {
const all = [...Object.keys(keys), ...Object.keys(DIR_WORDS)];
return err(`unknown aura setting "${w}"`, hooks.suggest(w, all) || `settings: ${Object.keys(keys).join(', ')}`);
}
i++;
if (def.kind === 'flag') {
if (w === 'glowing') { if (wantParticles) p.glowing = true; }
else setBoth(w, true);
continue;
}
if (def.kind === 'colors') {
const stops = [];
while (i < words.length && (words[i] === '->' || (!isKey(words[i]) && !isNumeric(words[i]) && looksLikeColor(words[i])))) {
if (words[i] === '->') { i++; continue; }
const css = hooks.color(words[i]);
if (css === null) return null;
stops.push(css);
i++;
}
if (!stops.length) return err('color needs at least one colour', def.doc);
if (wantGlow && 'color' in GLOW_KEYS) g.color = stops[0];
if (wantParticles) p.colors = stops;
continue;
}
if (def.kind === 'color') {
const css = hooks.color(words[i]);
if (css === null) return null;
i++;
setBoth(w, css);
if (w === 'color' && wantParticles) p.colors = [css];
continue;
}
if (def.kind === 'texture') {
const word = words[i];
i++;
const m = /^texture\((.*)\)$/s.exec(word || '');
const inner = m ? m[1] : word;
if (!inner) return err('texture needs a name', def.doc);
const t = textureOf(inner);
if (t === null) return null;
setBoth('texture', t);
continue;
}
if (def.kind === 'around') {
const v = words[i]; i++;
const sp = parseAround(v, hooks);
if (sp === null) return null;
setBoth('around', sp);
continue;
}
if (def.kind === 'dir') {
const v = words[i]; i++;
if (v in DIR_WORDS) p.dir = DIR_WORDS[v];
else if (isNumeric(v)) p.dir = Number(v);
else return err(`unknown direction "${v}"`, hooks.suggest(v, Object.keys(DIR_WORDS)) || 'up, down, left, right, out, in, or degrees');
continue;
}
if (def.kind === 'word') {
const v = words[i]; i++;
if (!def.values.includes(v)) return err(`unknown ${w} "${v}"`, hooks.suggest(v, def.values) || `use one of: ${def.values.join(', ')}`);
if (wantParticles) p[w] = v;
continue;
}
if (def.kind === 'range') {
const vals = [];
while (i < words.length && vals.length < 2 && isNumeric(words[i])) { vals.push(Number(words[i])); i++; }
if (!vals.length) return err(`${w} needs a number`, def.doc);
if (vals.some((n) => n < def.min || n > def.max)) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc);
if (wantParticles) p[w] = vals.length === 1 ? [vals[0], vals[0]] : [Math.min(...vals), Math.max(...vals)];
continue;
}
// plain number
const raw = words[i]; i++;
if (!isNumeric(raw)) return err(`${w} needs a number, got "${raw ?? ''}"`, def.doc);
const n = Number(raw);
if (n < def.min || n > def.max) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc);
setBoth(w === 'turbulence' ? 'turbulence' : w, def.int ? Math.round(n) : n);
}
if (opts.forceGlowing && wantParticles) p.glowing = true;
const out = {};
if (wantGlow && (kind === 'glow' || Object.keys(g).length || opts.requireGlow)) out.glow = { kind: 'glow', ...g };
if (wantParticles && (kind === 'particles' || Object.keys(p).length || opts.requireParticles)) out.particles = { kind: 'particles', ...p };
return out;
}
/**
* Parse the value of one of the aura properties. `prop` decides the shape:
* 'aura' layers need a leading glow / particles word
* 'aura-glow' one glow layer
* 'aura-particle' one particles layer
* 'glow-particle' one particles layer, glowing
* 'aura-glow-particle' a glow layer AND a glowing particles layer, settings shared
* Returns { glow: [layers]|null, particles: [layers]|null } where null means "clear this slot",
* or null when invalid.
*/
export function parseAura(prop, value, hooks) {
const text = String(value || '').trim();
if (!text) { hooks.error(`${prop} needs a value`, 'e.g. aura-glow: color.accent 24'); return null; }
const base = prop.replace(/s$/, ''); // aura-particles -> aura-particle
if (text === 'none') {
// `none` clears exactly the slots this property owns, so aura-glow: none leaves particles alone
const clears = { 'aura': { glow: null, particles: null }, 'aura-glow': { glow: null }, 'aura-particle': { particles: null }, 'glow-particle': { particles: null }, 'aura-glow-particle': { glow: null, particles: null } };
return clears[base] || { glow: null, particles: null };
}
if (prop === 'aura') {
const glow = [], particles = [];
for (const part of splitCommas(text)) {
const words = splitWords(part);
const head = words[0];
let kind;
if (head === 'glow') kind = 'glow';
else if (head === 'particles' || head === 'particle') kind = 'particles';
else {
hooks.error(`an aura layer starts with glow or particles, not "${head}"`, hooks.suggest(head, ['glow', 'particles']) || 'e.g. aura: glow color.accent 24, particles #fc4 count 40');
return null;
}
const r = parseLayerWords(words.slice(1), kind, hooks);
if (!r) return null;
if (r.glow) glow.push(r.glow);
if (r.particles) particles.push(r.particles);
}
return { glow, particles };
}
const out = { glow: undefined, particles: undefined };
const layersOf = (kind, opts) => {
const arr = [];
for (const part of splitCommas(text)) {
const r = parseLayerWords(splitWords(part), kind, hooks, opts);
if (!r) return null;
if (r.glow) arr.push(r.glow);
if (r.particles) arr.push(r.particles);
}
return arr;
};
if (base === 'aura-glow') { const a = layersOf('glow'); if (!a) return null; out.glow = a; }
else if (base === 'aura-particle') { const a = layersOf('particles'); if (!a) return null; out.particles = a; }
else if (base === 'glow-particle') { const a = layersOf('particles', { forceGlowing: true }); if (!a) return null; out.particles = a; }
else if (base === 'aura-glow-particle') {
const glow = [], particles = [];
for (const part of splitCommas(text)) {
const r = parseLayerWords(splitWords(part), 'both', hooks, { forceGlowing: true, requireGlow: true, requireParticles: true });
if (!r) return null;
if (r.glow) glow.push(r.glow);
if (r.particles) particles.push(r.particles);
}
out.glow = glow; out.particles = particles;
} else { hooks.error(`unknown aura property "${prop}"`, null); return null; }
return out;
}
/* ------------------------------------------------------------------ *
* turning a parsed layer into numbers the runtime can use
* ------------------------------------------------------------------ */
export const GLOW_DEFAULTS = { radius: 24, intensity: 1, pulse: 0, depth: 0.35, flicker: 0, seed: 1 };
export const PARTICLE_DEFAULTS = {
count: 40, size: [4, 4], life: [900, 900], speed: [40, 40], dir: -90, spread: 30,
gravity: 0, drag: 0, turbulence: 0, from: 'edge', shape: 'circle', grow: 0.3, blend: null, glowing: false, seed: 1,
};
/**
* Resolve a parsed layer for drawing. `rc(cssColor)` returns {r,g,b,a} or null,
* `fallback` is the colour used when the layer names none (the element's text colour).
*/
export function resolveLayer(layer, rc, fallback) {
const pick = (css) => (css === undefined || /^currentcolor$/i.test(css) ? fallback : (rc(css) || fallback));
if (layer.kind === 'glow') {
return { ...GLOW_DEFAULTS, ...layer, rgba: pick(layer.color), tex: layer.texture && layer.texture.length ? layer.texture : null };
}
const stops = (layer.colors && layer.colors.length ? layer.colors : [undefined]).map(pick);
if (stops.length === 1) stops.push(withAlpha(stops[0], 0)); // a single colour fades out
const glowing = !!layer.glowing;
return {
...PARTICLE_DEFAULTS, ...layer, stops, glowing, behind: !!layer.behind,
blend: layer.blend || (glowing ? 'add' : 'normal'),
tex: layer.texture && layer.texture.length ? layer.texture : null,
};
}
/* ====================================================================== *
* RUNTIME. Everything below needs a browser.
* ====================================================================== */
const TAU = Math.PI * 2;
const lerp = (a, b, t) => a + (b - a) * t;
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/* ---- geometry of an element's real shape (0.16.0) ---- */
const ANGLE_UNIT = { deg: Math.PI / 180, rad: 1, turn: TAU, grad: Math.PI / 200 };
/** the 2x2 part of everything the element does to itself: rotate, scale and transform (its translation is already in its rect) */
function linearOf(cs) {
let a = 1, b = 0, c = 0, d = 1, any = false;
const mul = (ma, mb, mc, md) => { // cur = cur * m (m is applied to a point first)
const na = a * ma + c * mb, nb = b * ma + d * mb, nc = a * mc + c * md, nd = b * mc + d * md;
a = na; b = nb; c = nc; d = nd; any = true;
};
const rot = cs.rotate;
if (rot && rot !== 'none') {
const m = /^(?:z\s+)?(-?[\d.]+)(deg|rad|turn|grad)$/.exec(rot);
if (m) { const t = parseFloat(m[1]) * ANGLE_UNIT[m[2]]; mul(Math.cos(t), Math.sin(t), -Math.sin(t), Math.cos(t)); }
}
const sc = cs.scale;
if (sc && sc !== 'none') {
const m = /^(-?[\d.]+)(?:\s+(-?[\d.]+))?/.exec(sc);
if (m) { const sx = parseFloat(m[1]), sy = m[2] !== undefined ? parseFloat(m[2]) : sx; mul(sx, 0, 0, sy); }
}
const tr = cs.transform;
if (tr && tr !== 'none') {
let m = /^matrix\(([^)]*)\)$/.exec(tr);
if (m) { const v = m[1].split(',').map(parseFloat); if (v.length === 6 && v.every(Number.isFinite)) mul(v[0], v[1], v[2], v[3]); }
else if ((m = /^matrix3d\(([^)]*)\)$/.exec(tr))) { const v = m[1].split(',').map(parseFloat); if (v.length === 16 && v.every(Number.isFinite)) mul(v[0], v[1], v[4], v[5]); }
}
return any ? { a, b, c, d } : null;
}
/** clip-path: polygon / circle / ellipse / inset to a shape in fractions of the box; null for anything else (path(), url(), calc) */
const clipCache = new Map();
function parseClip(str) {
if (!str || str === 'none') return null;
if (clipCache.has(str)) return clipCache.get(str);
let out = null;
const val = (t, size) => (/%$/.test(t) ? parseFloat(t) / 100 : /px$/.test(t) ? parseFloat(t) / size : /^-?[\d.]+$/.test(t) ? (parseFloat(t) === 0 ? 0 : NaN) : NaN);
try {
let m;
if ((m = /^polygon\((?:(?:nonzero|evenodd)\s*,\s*)?(.*)\)$/s.exec(str))) {
const pts = m[1].split(',').map((p) => p.trim().split(/\s+/));
if (pts.length >= 3 && pts.every((p) => p.length === 2)) out = { kind: 'poly', pts: pts.map((p) => ({ x: p[0], y: p[1] })) };
} else if ((m = /^(circle|ellipse)\((.*)\)$/s.exec(str))) {
const at = m[2].split(/\s+at\s+/);
const radii = at[0].trim().split(/\s+/).filter(Boolean);
const pos = (at[1] || '50% 50%').trim().split(/\s+/);
out = { kind: 'ellipse', circle: m[1] === 'circle', radii, cx: pos[0], cy: pos[1] === undefined ? '50%' : pos[1] };
} else if ((m = /^inset\(([^)]*?)(?:\s+round\s+.*)?\)$/s.exec(str))) {
out = { kind: 'inset', v: m[1].trim().split(/\s+/) };
}
} catch { out = null; }
if (clipCache.size > 200) clipCache.clear();
clipCache.set(str, out);
return out;
}
/** a shape in the element's own box, centred on (0, 0), sized w by h, in CSS px */
function resolveGeom(spec, clipStr, w, h, radius, srcEl) {
const name = spec ? spec.name : 'follow';
const pct = (t, size) => { const v = /%$/.test(t) ? parseFloat(t) / 100 * size : /px$/.test(t) ? parseFloat(t) : /^-?[\d.]+$/.test(t) ? parseFloat(t) : NaN; return v; };
const polyFrom = (pts) => ({ kind: 'poly', pts: pts.map(([fx, fy]) => [(fx - 0.5) * w, (fy - 0.5) * h]) });
if (name === 'follow') {
const cl = parseClip(clipStr);
if (cl) {
if (cl.kind === 'poly') {
const pts = cl.pts.map((p) => [pct(p.x, w) - w / 2, pct(p.y, h) - h / 2]);
if (pts.every((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]))) return { kind: 'poly', pts, sig: 'clip:' + clipStr };
} else if (cl.kind === 'ellipse') {
const cx = pct(cl.cx, w) - w / 2, cy = pct(cl.cy, h) - h / 2;
let rx, ry;
if (cl.circle) { const ref = Math.sqrt((w * w + h * h) / 2); const r = cl.radii[0] ? (/%$/.test(cl.radii[0]) ? parseFloat(cl.radii[0]) / 100 * ref : pct(cl.radii[0], ref)) : ref / 2; rx = ry = r; }
else { rx = cl.radii[0] ? pct(cl.radii[0], w) : w / 2; ry = cl.radii[1] ? pct(cl.radii[1], h) : h / 2; }
if ([cx, cy, rx, ry].every(Number.isFinite)) return { kind: 'ellipse', cx, cy, rx, ry, sig: 'clip:' + clipStr };
} else if (cl.kind === 'inset') {
const v = cl.v.map((t, i) => pct(t, i % 2 ? w : h));
const [t, r, b = t, l = r] = v.length === 1 ? [v[0], v[0], v[0], v[0]] : v.length === 2 ? [v[0], v[1], v[0], v[1]] : v;
if ([t, r, b, l].every(Number.isFinite)) return { kind: 'poly', pts: [[-w / 2 + l, -h / 2 + t], [w / 2 - r, -h / 2 + t], [w / 2 - r, h / 2 - b], [-w / 2 + l, h / 2 - b]], sig: 'clip:' + clipStr };
}
}
return { kind: 'rrect', r: radius, sig: 'rr' + radius };
}
if (name === 'box') return { kind: 'rrect', r: radius, sig: 'rr' + radius };
if (name === 'pill') return { kind: 'rrect', r: Math.min(w, h) / 2, sig: 'pill' };
if (name === 'round') return { kind: 'ellipse', cx: 0, cy: 0, rx: w / 2, ry: h / 2, sig: 'round' };
if (name === 'alpha') {
const ok = srcEl && ((srcEl.tagName === 'IMG' && srcEl.complete && srcEl.naturalWidth > 0) || srcEl.tagName === 'CANVAS' || (srcEl.tagName === 'VIDEO' && srcEl.readyState >= 2));
if (ok) return { kind: 'alpha', src: srcEl, sig: 'alpha' + (srcEl.currentSrc || srcEl.src || '') };
return { kind: 'rrect', r: radius, sig: 'rr' + radius };
}
const pts = aroundPoints(spec);
if (pts) return { ...polyFrom(pts), sig: 'poly' + name + w + 'x' + h };
return { kind: 'rrect', r: radius, sig: 'rr' + radius };
}
/** add a shape (centred, w by h) to the current path */
function tracePath(ctx, g, w, h) {
if (g.kind === 'ellipse') { ctx.moveTo(g.cx + g.rx, g.cy); ctx.ellipse(g.cx, g.cy, Math.max(0.01, g.rx), Math.max(0.01, g.ry), 0, 0, TAU); ctx.closePath(); }
else if (g.kind === 'poly') { g.pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.closePath(); }
else roundRectPath(ctx, -w / 2, -h / 2, w, h, g.kind === 'rrect' ? g.r : 0);
}
function roundRectPath(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function colorAt(stops, t) {
const n = stops.length - 1;
if (n <= 0) return stops[0];
const f = clamp01(t) * n;
const i = Math.min(n - 1, Math.floor(f));
const k = f - i;
const a = stops[i], b = stops[i + 1];
return { r: lerp(a.r, b.r, k), g: lerp(a.g, b.g, k), b: lerp(a.b, b.b, k), a: lerp(a.a, b.a, k) };
}
/** A tiny signature, used to tell "same settings" from "changed" without deep compares */
const sigOf = (x) => JSON.stringify(x);
export class AuraManager {
/**
* opts.reducedMotion: a MediaQueryList (or anything with .matches)
* opts.budget: the most particles alive across the whole page
*/
constructor(opts = {}) {
this.reducedMotion = opts.reducedMotion || null;
this.budget = opts.budget || 1800;
this.emitters = new Map(); // element -> emitter
this.canvas = null;
this.ctx = null;
this.dpr = 1;
this.vw = 0; this.vh = 0;
this.running = false;
this.paused = false;
this.last = 0;
this.sprites = new Map();
this.masks = new Map();
this.io = null;
this.stats = { particles: 0, emitters: 0, frameMs: 0, fps: 0, frames: 0 };
this._fpsT = 0; this._fpsN = 0;
this._onResize = () => this.resize();
// reduced motion draws a still picture, so it only needs a new frame when something could have moved
this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
this._poll = null;
this.extra = new Map(); // z-index -> { canvas, ctx }: layers asked for with `z N`
this._frame = (t) => this.frame(t);
}
get reduced() { return !!(this.reducedMotion && this.reducedMotion.matches); }
/* ---- setup ---- */
ensureCanvas() {
if (this.canvas) return;
const c = document.createElement('canvas');
c.setAttribute('aria-hidden', 'true');
c.setAttribute('data-dssl-aura', '');
// 0.14.0: the layer sits above the page by default. <html data-dssl-aura-z="20"> (or DSSL.auraZ(20)) lowers it, so a menu or a
// glass overlay with a higher z-index covers the light the way it covers everything else.
const z = parseInt(document.documentElement.getAttribute('data-dssl-aura-z'), 10);
c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${Number.isFinite(z) ? z : 2147483000};`;
(document.body || document.documentElement).appendChild(c);
this.canvas = c;
this.ctx = c.getContext('2d');
this.resize();
window.addEventListener('resize', this._onResize, { passive: true });
window.addEventListener('scroll', this._onScroll, { passive: true, capture: true });
if (typeof IntersectionObserver !== 'undefined') {
this.io = new IntersectionObserver((entries) => {
for (const e of entries) {
const em = this.emitters.get(e.target);
if (em) { em.visible = e.isIntersecting; if (e.isIntersecting) this.start(); }
}
}, { rootMargin: '200px' });
}
}
resize() {
if (!this.canvas) return;
this.dpr = Math.min(2, window.devicePixelRatio || 1);
this.vw = window.innerWidth; this.vh = window.innerHeight;
for (const c of this.allCanvases()) {
c.width = Math.max(1, Math.round(this.vw * this.dpr));
c.height = Math.max(1, Math.round(this.vh * this.dpr));
}
this.dirty = true;
}
allCanvases() { return [this.canvas, ...[...this.extra.values()].map((e) => e.canvas)]; }
/** the drawing context for a layer: the default canvas, or the one at the z-index the layer asked for (`z 5`) */
ctxFor(z) {
if (z === undefined || z === null) return this.ctx;
let e = this.extra.get(z);
if (!e) {
const c = document.createElement('canvas');
c.setAttribute('aria-hidden', 'true');
c.setAttribute('data-dssl-aura', String(z));
c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${z};`;
c.width = this.canvas.width; c.height = this.canvas.height;
(document.body || document.documentElement).appendChild(c);
e = { canvas: c, ctx: c.getContext('2d') };
this.extra.set(z, e);
}
return e.ctx;
}
/** how visible an element really is: its opacity times every ancestor's; 0 for display: none or visibility: hidden */
effectiveOpacity(el) {
try {
let o = 1;
if (getComputedStyle(el).visibility !== 'visible') return 0;
for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
const cs = getComputedStyle(a);
if (cs.display === 'none') return 0;
o *= parseFloat(cs.opacity);
if (!(o > 0.004)) return 0;
}
return o;
} catch { return 1; }
}
/** what a frame would draw for this emitter, as a string: the same string twice means nothing needs redrawing */
stateKey(el, eo) {
const r = el.getBoundingClientRect();
let extra = '';
try { const cs = getComputedStyle(el); extra = `${cs.transform}|${cs.rotate}|${cs.scale}|${cs.clipPath}`; } catch { /* detached */ }
return `${r.left.toFixed(1)},${r.top.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)},${eo.toFixed(2)},${extra}`;
}
/** reduced motion: has anything moved, resized or faded since the last frame? (cheap: no drawing) */
peek() {
for (const [el, em] of this.emitters) {
if (!el.isConnected) return true;
if (this.stateKey(el, this.effectiveOpacity(el)) !== em.key) return true;
}
return false;
}
/* ---- the engine's interface ---- */
/** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
set(el, glow, particles) {
const hasGlow = glow && glow.length, hasParts = particles && particles.length;
if (!hasGlow && !hasParts) { this.remove(el); return; }
this.ensureCanvas();
let em = this.emitters.get(el);
if (!em) {
em = { el, visible: true, rect: null, radius: 0, glow: [], layers: [], dying: false, fade: 1, sig: '', age: 0, radiusAt: -1 };
this.emitters.set(el, em);
if (this.io) this.io.observe(el);
}
em.dying = false;
const sig = sigOf([glow, particles]);
if (sig === em.sig) { this.start(); return; }
em.sig = sig;
em.glow = (glow || []).map((g, i) => ({ cfg: g, rnd: rng((g.seed || 1) * 7919 + i), flick: 0, flickTarget: 0, t0: performance.now() }));
// keep running particles when the layer list keeps its shape; otherwise start fresh
const old = em.layers;
em.layers = (particles || []).map((cfg, i) => {
const prev = old[i];
const keep = prev && prev.parts;
return { cfg, rnd: keep ? prev.rnd : rng((cfg.seed || 1) * 104729 + i), parts: keep ? prev.parts : [], acc: keep ? prev.acc : 0 };
});
this.start();
}
remove(el) {
const em = this.emitters.get(el);
if (!em) return;
em.dying = true; // stop emitting, let what is alive finish, fade the glow
this.start();
}
pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); }
resume() { this.paused = false; this.start(); }
destroy() {
this.running = false;
window.removeEventListener('resize', this._onResize);
window.removeEventListener('scroll', this._onScroll, { capture: true });
if (this._poll) { clearTimeout(this._poll); this._poll = null; }
if (this.io) this.io.disconnect();
for (const e of this.extra.values()) e.canvas.remove();
this.extra.clear();
if (this.canvas) this.canvas.remove();
this.canvas = null; this.ctx = null; this.emitters.clear();
}
/* ---- loop ---- */
start() {
if (this.running || this.paused || !this.canvas) return;
this.running = true;
this.last = 0;
requestAnimationFrame(this._frame);
}
frame(now) {
if (!this.running) return;
const t0 = performance.now();
const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
this.last = now;
const reduced = this.reduced;
const dpr = this.dpr;
for (const c of this.allCanvases()) {
const x = c.getContext('2d');
x.setTransform(1, 0, 0, 1, 0, 0);
x.clearRect(0, 0, c.width, c.height);
x.setTransform(dpr, 0, 0, dpr, 0, 0);
}
let changed = false;
// global budget: if everyone asks for more than the page can afford, everyone gets a share
let want = 0;
for (const em of this.emitters.values()) if (em.visible && !em.dying) for (const l of em.layers) want += l.cfg.count;
const share = want > this.budget ? this.budget / want : 1;
let alive = 0, anyActive = false;
this._n = (this._n || 0) + 1;
for (const [el, em] of this.emitters) {
if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
// read geometry once per frame for visible emitters
let rect = null;
if (em.visible || em.layers.some((l) => l.parts.length)) {
rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) rect = null;
}
em.rect = rect;
// 0.15.0: an emitter that cannot be seen (opacity 0 on it or any ancestor, display: none, visibility: hidden) does not emit, and
// what it already let go fades with it. Looked up every third frame, or every frame in reduced motion where frames are rare.
if (em.eo === undefined || reduced || this._n % 3 === 0) em.eo = this.effectiveOpacity(el);
if (em.layers.some((l) => l.cfg.always) || em.glow.some((g) => g.cfg.always)) em.eo = 1; // an invisible anchor on purpose
const key = this.stateKey(el, em.eo);
if (key !== em.key) { em.key = key; changed = true; }
// 0.14.0: a stage scaled with transform: scale() has a rect the size you SEE, so the light follows it, and so must its size
// and speed. The scale is how much bigger (or smaller) the box is than the box CSS says it is.
em.scale = 1;
if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; em.clips = this.clipAncestors(el, em); }
// 0.16.0: where the element REALLY is: its rect is the box around the turned/scaled shape, so work out the shape's own size, the way it
// is turned (rotate, scale, transform) and how much a parent scales it. Glow, edge particles and the hidden-behind clip all use this.
em.base = rect ? this.baseOf(el, rect) : null;
if (em.base) em.scale = em.base.s;
if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
else em.fade = Math.min(1, em.fade + dt * 4);
// 0.14.0: cut the light at the edge of any parent that clips, so a stage with overflow: hidden keeps its fire inside
const clipped = rect && em.clips && em.clips.length ? this.clipRect(em.clips) : null;
const open = (ctx) => { if (clipped) { ctx.save(); ctx.beginPath(); ctx.rect(clipped.x, clipped.y, clipped.w, clipped.h); ctx.clip(); } };
const close = (ctx) => { if (clipped) ctx.restore(); };
const seen = em.eo > 0.004;
// glow
if (rect && em.fade > 0 && (em.visible || em.dying) && seen) {
for (const g of em.glow) { const ctx = this.ctxFor(g.cfg.z); open(ctx); this.drawGlow(ctx, rect, em, g, now, reduced); close(ctx); }
if (em.glow.length) anyActive = true;
}
// particles
for (const L of em.layers) {
const cfg = L.cfg;
const moving = !reduced || cfg.essential;
L.scale = em.scale;
L.eo = em.eo;
if (em.base) { L.cx = em.base.cx; L.cy = em.base.cy; }
if (rect && em.visible && !em.dying && moving && seen) this.emit(L, rect, em, dt, share, el);
if (L.parts.length) { const ctx = this.ctxFor(cfg.z); open(ctx); this.stepAndDraw(ctx, L, dt, moving, cfg.behind && em.base ? { base: em.base, geom: this.geomFor(em, cfg) } : null); close(ctx); }
alive += L.parts.length;
if (L.parts.length || (rect && em.visible && !em.dying && moving && seen)) anyActive = true;
}
if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
this.emitters.delete(el);
if (this.io) this.io.unobserve(el);
}
}
this.stats.particles = alive;
this.stats.emitters = this.emitters.size;
this.stats.frameMs = performance.now() - t0;
this.stats.frames++;
this._fpsN++;
if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
if (this.emitters.size === 0) { this.running = false; for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); return; }
// Normally the loop runs every frame (the glow follows the element, particles move). With reduced
// motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
// style change, and a quick look (peek) for anything that moved. A frame that saw something move, resize or fade asks for the next one,
// so a glow on a thing that moves under reduced motion follows it every frame instead of lagging. Essential layers and fading-out emitters still loop.
let needLoop = !reduced;
if (reduced) {
if (changed) needLoop = true;
for (const em of this.emitters.values()) {
if (em.dying) needLoop = true;
if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
}
}
if (needLoop) { requestAnimationFrame(this._frame); return; }
this.running = false;
this.watchStill();
}
/** reduced motion, nothing moving: look every 100ms (a few rect reads, no drawing) and draw again the moment something changed */
watchStill() {
if (this._poll || this.paused) return;
this._poll = setTimeout(() => {
this._poll = null;
if (this.running || !this.canvas) return;
if (this.peek()) this.start(); else this.watchStill();
}, 100);
}
/** the ancestors that clip their children (overflow other than visible), unless this emitter said `unclipped` */
clipAncestors(el, em) {
if (em.layers.some((l) => l.cfg.unclipped) || em.glow.some((g) => g.cfg.unclipped)) return [];
const out = [];
try {
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
const cs = getComputedStyle(a);
if (/(hidden|clip|auto|scroll)/.test(cs.overflowX + ' ' + cs.overflowY)) out.push(a);
}
} catch { /* detached */ }
return out;
}
/** the visible box: the padding boxes of every clipping ancestor, intersected. Null when nothing clips. */
clipRect(list) {
let x0 = -1e9, y0 = -1e9, x1 = 1e9, y1 = 1e9;
for (const a of list) {
const r = a.getBoundingClientRect();
const sx = a.offsetWidth ? r.width / a.offsetWidth : 1, sy = a.offsetHeight ? r.height / a.offsetHeight : 1;
x0 = Math.max(x0, r.left + a.clientLeft * sx); y0 = Math.max(y0, r.top + a.clientTop * sy);
x1 = Math.min(x1, r.left + (a.clientLeft + a.clientWidth) * sx); y1 = Math.min(y1, r.top + (a.clientTop + a.clientHeight) * sy);
}
return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
}
cornerRadius(el, rect) {
try {
const raw = getComputedStyle(el).borderTopLeftRadius;
const w = el.offsetWidth || (rect ? rect.width : 0), h = el.offsetHeight || (rect ? rect.height : 0);
// a percentage (border-radius: 50%) is a share of the box, not that many px
const v = /%$/.test(raw) ? (parseFloat(raw) / 100) * Math.min(w, h) : parseFloat(raw) || 0;
return Math.min(v, Math.min(w, h) / 2);
} catch { return 0; }
}
/** the element's own box, centre and the way it is turned, scaled and clipped, read from the page */
baseOf(el, rect) {
let L = null, clip = 'none';
try { const cs = getComputedStyle(el); L = linearOf(cs); clip = cs.clipPath; } catch { /* detached */ }
let w = el.offsetWidth, h = el.offsetHeight;
let a = 1, b = 0, c = 0, d = 1;
if (!w || !h) { w = rect.width; h = rect.height; L = null; }
if (L) ({ a, b, c, d } = L);
const ew = Math.abs(a) * w + Math.abs(c) * h;
const s = ew > 0 ? Math.max(0.05, Math.min(20, rect.width / ew)) : 1;
return { cx: rect.left + rect.width / 2, cy: rect.top + rect.height / 2, w, h, a, b, c, d, s, clip, turned: !!L };
}
/** the shape a layer follows (its `around`, else the element's clip-path or rounded corners), cached while nothing about it changes */
geomFor(em, cfg) {
const base = em.base;
const key = `${cfg.around ? cfg.around.name + (cfg.around.pts ? cfg.around.pts.length : '') : 'follow'}|${base.w}x${base.h}|${base.clip}|${em.radius}`;
const asks = cfg.around && cfg.around.name === 'alpha'; // an image can finish loading at any moment, so look again every frame
if (!asks && cfg._gc && cfg._gc.key === key) return cfg._gc.geom;
const geom = resolveGeom(cfg.around, base.clip, base.w, base.h, em.radius, em.el);
cfg._gc = { key, geom };
return geom;
}
/** put the shape into the current path of ctx, in page coordinates, whatever transform ctx has now */
addShape(ctx, base, geom) {
const m = ctx.getTransform();
ctx.transform(base.a * base.s, base.b * base.s, base.c * base.s, base.d * base.s, base.cx, base.cy);
tracePath(ctx, geom, base.w, base.h);
ctx.setTransform(m);
}
/** the outline of the shape as a closed polyline in page coordinates, with the length of each side, for particles born "from outline" */
outlineOf(em, cfg) {
const base = em.base;
if (!base) return null;
const geom = this.geomFor(em, cfg);
if (geom._olN === this._n && geom._olBase === base) return geom._ol;
let local;
if (geom.kind === 'poly') local = geom.pts;
else if (geom.kind === 'ellipse') { local = []; for (let i = 0; i < 48; i++) { const t = (i / 48) * TAU; local.push([geom.cx + geom.rx * Math.cos(t), geom.cy + geom.ry * Math.sin(t)]); } }
else { const hw = base.w / 2, hh = base.h / 2; local = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; }
const pts = local.map(([x, y]) => ({ x: base.cx + base.s * (base.a * x + base.c * y), y: base.cy + base.s * (base.b * x + base.d * y) }));
const lens = []; let total = 0;
for (let i = 0; i < pts.length; i++) { const q = pts[(i + 1) % pts.length]; const l = Math.hypot(q.x - pts[i].x, q.y - pts[i].y); lens.push(l); total += l; }
geom._ol = { pts, lens, total }; geom._olN = this._n; geom._olBase = base;
return geom._ol;
}
/* ---- glow ---- */
drawGlow(ctx, rect, em, g, now, reduced) {
const cfg = g.cfg;
const base = em.base;
if (!base) return;
const moving = !reduced || cfg.essential;
let a = cfg.intensity * em.fade * (em.eo === undefined ? 1 : em.eo);
if (moving && cfg.pulse > 0) {
const ph = ((now - g.t0) / cfg.pulse) * TAU;
a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
}
if (moving && cfg.flicker > 0) {
// smooth random flicker: ease toward a new random target a few times a second
if (Math.random() < 0.18) g.flickTarget = g.rnd();
g.flick += (g.flickTarget - g.flick) * 0.35;
a *= 1 - cfg.flicker * g.flick * 0.9;
}
if (a <= 0.004) return;
const c = cfg.rgba;
const s = base.s;
const geom = this.geomFor(em, cfg);
// 0.16.0: beams and ripples, drawn straight onto the canvas (they move, so there is nothing to cache)
if (cfg.rays) this.drawRays(ctx, em, geom, cfg, a, now, moving);
if (cfg.ripple) this.drawRipple(ctx, em, geom, cfg, a, now, moving);
if (cfg.nohalo && (cfg.rays || cfg.ripple)) return;
const R = cfg.radius * s;
const dpr = this.dpr;
const pad = Math.ceil(R * 1.6);
const lw = Math.ceil(rect.width + pad * 2), lh = Math.ceil(rect.height + pad * 2);
if (lw * lh * dpr * dpr > 6e6) return; // a glow bitmap bigger than this would cost more than it is worth
// The blur is the expensive part, and pulsing and flickering only change how BRIGHT the shape is, not
// what it looks like. So the shape is painted once into this glow's own bitmap, and each frame just
// draws that bitmap at the current alpha. It repaints only when the shape really changes.
const m = [base.a, base.b, base.c, base.d].map((n) => n.toFixed(3)).join(',');
const live = geom.kind === 'alpha' && geom.src && geom.src.tagName !== 'IMG' ? Math.floor(now / 120) : ''; // a canvas or video changes under us
const key = `${lw}x${lh}|${dpr}|${c.r | 0},${c.g | 0},${c.b | 0},${c.a.toFixed(2)}|${R.toFixed(1)}|${cfg.inner ? 1 : 0}|${geom.sig}|${m}|${base.w}x${base.h}|${s.toFixed(3)}|${cfg.tex ? sigOf(cfg.tex[0]) : ''}|${live}`;
if (!g.bitmap || g.bitmapKey !== key) {
if (!g.bitmap) g.bitmap = document.createElement('canvas');
const off = g.bitmap;
off.width = Math.ceil(lw * dpr); off.height = Math.ceil(lh * dpr);
const o = off.getContext('2d');
const SH = 10000; // draw the shape far off-canvas and let only its blurred shadow land here
// the shape in bitmap pixels: centred, turned and scaled like the element; shiftDev slides it sideways in device pixels
const put = (shiftDev) => {
o.setTransform(dpr * base.a * s, dpr * base.b * s, dpr * base.c * s, dpr * base.d * s, dpr * (lw / 2) + shiftDev, dpr * (lh / 2));
if (geom.kind === 'alpha') { try { o.drawImage(geom.src, -base.w / 2, -base.h / 2, base.w, base.h); } catch { /* an image that cannot be drawn */ } }
else tracePath(o, geom, base.w, base.h);
o.setTransform(1, 0, 0, 1, 0, 0);
};
const addPath = () => { o.setTransform(dpr * base.a * s, dpr * base.b * s, dpr * base.c * s, dpr * base.d * s, dpr * (lw / 2), dpr * (lh / 2)); tracePath(o, geom.kind === 'alpha' ? { kind: 'rrect', r: 0 } : geom, base.w, base.h); o.setTransform(1, 0, 0, 1, 0, 0); };
const shadowOf = (shiftDev, blur, color) => {
o.shadowOffsetX = SH * dpr; o.shadowBlur = blur * dpr; o.shadowColor = color;
if (geom.kind === 'alpha') put(-SH * dpr);
else { o.beginPath(); put(-SH * dpr); o.fillStyle = '#000'; o.fill(); }
};
o.save();
if (cfg.inner) {
// light just inside the edge: fill the shape with the colour, then wipe out its blurred core
if (geom.kind === 'alpha') { o.fillStyle = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a)})`; o.fillRect(0, 0, off.width, off.height); o.globalCompositeOperation = 'destination-in'; o.shadowColor = 'transparent'; put(0); }
else {
o.beginPath(); addPath(); o.clip();
o.fillStyle = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a)})`;
o.fillRect(0, 0, off.width, off.height);
}
o.globalCompositeOperation = 'destination-out';
shadowOf(0, R * 0.9, 'rgba(0,0,0,1)');
} else {
// outside: the shape itself is cut out of the clip, so the light never paints over the element
if (geom.kind !== 'alpha') { o.beginPath(); o.rect(0, 0, off.width, off.height); addPath(); o.clip('evenodd'); }
for (const [k, mul] of [[1, 0.8], [0.55, 0.45], [0.25, 0.4]]) {
shadowOf(0, R * k, `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a * mul)})`);
}
}
o.restore();
if (!cfg.inner && geom.kind === 'alpha') {
o.save(); o.globalCompositeOperation = 'destination-out'; o.shadowColor = 'transparent'; o.shadowBlur = 0; o.shadowOffsetX = 0; put(0); o.restore();
}
if (cfg.tex) {
const mask = this.maskCanvas(cfg.tex[0], 128);
o.save();
o.globalCompositeOperation = 'destination-in';
const pat = o.createPattern(mask, 'repeat');
if (pat) { o.fillStyle = pat; o.fillRect(0, 0, off.width, off.height); }
o.restore();
}
g.bitmapKey = key;
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// intensity above 1 brightens by drawing the same light more than once
const passes = Math.max(1, Math.ceil(a));
ctx.globalAlpha = clamp01(a / passes);
for (let i = 0; i < passes; i++) ctx.drawImage(g.bitmap, 0, 0, g.bitmap.width, g.bitmap.height, base.cx - lw / 2, base.cy - lh / 2, lw, lh);
ctx.restore();
}
/** `rays N`: beams of light from the middle of the shape, turning slowly, never over the element itself */
drawRays(ctx, em, geom, cfg, a, now, moving) {
const base = em.base, c = cfg.rgba, n = cfg.rays;
const len = (Math.hypot(base.w, base.h) / 2 + cfg.radius) * base.s;
const turn = moving ? ((now / 1000) * (cfg.spin === undefined ? 10 : cfg.spin) * Math.PI) / 180 : 0;
ctx.save();
if (geom.kind !== 'alpha') { ctx.beginPath(); ctx.rect(-50, -50, this.vw + 100, this.vh + 100); this.addShape(ctx, base, geom); ctx.clip('evenodd'); }
ctx.globalCompositeOperation = 'lighter';
ctx.translate(base.cx, base.cy);
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, len);
grad.addColorStop(0, `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a * a * 0.85)})`);
grad.addColorStop(1, `rgba(${c.r | 0},${c.g | 0},${c.b | 0},0)`);
ctx.fillStyle = grad;
const half = (Math.PI / n) * 0.42;
for (let i = 0; i < n; i++) {
const ang = turn + (i / n) * TAU;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, len, ang - half, ang + half); ctx.closePath(); ctx.fill();
}
ctx.restore();
}
/** `ripple P`: three rings that spread out from the outline and fade; one still ring under reduced motion */
drawRipple(ctx, em, geom, cfg, a, now, moving) {
const base = em.base, c = cfg.rgba, P = cfg.ripple;
const reach = (cfg.radius * base.s) / Math.max(1, (Math.min(base.w, base.h) * base.s) / 2);
const phase0 = moving ? now / P : 0.4;
for (let k = 0; k < 3; k++) {
const t = (phase0 + k / 3) % 1;
const ga = clamp01(a * (1 - t) * (moving ? 1 : 0.6));
if (ga < 0.01) continue;
const grow = 1 + t * reach;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.transform(base.a * base.s * grow, base.b * base.s * grow, base.c * base.s * grow, base.d * base.s * grow, base.cx, base.cy);
ctx.beginPath();
tracePath(ctx, geom.kind === 'alpha' ? { kind: 'rrect', r: 0 } : geom, base.w, base.h);
ctx.lineWidth = 2 / (base.s * grow);
ctx.strokeStyle = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${ga})`;
ctx.stroke();
ctx.restore();
}
}
/* ---- masks and sprites ---- */
maskCanvas(texLayer, size) {
const key = sigOf([texLayer, size]);
let c = this.masks.get(key);
if (c) return c;
const data = makeMask(texLayer, size);
c = document.createElement('canvas');
c.width = c.height = size;
const x = c.getContext('2d');
const img = x.createImageData(size, size);
for (let i = 0; i < data.length; i++) { img.data[i * 4 + 3] = data[i]; img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = 255; }
x.putImageData(img, 0, 0);
this.masks.set(key, c);
return c;
}
/** a soft round sprite in one colour, optionally textured; cached by quantised colour */
sprite(c, shape, tex, variant) {
const q = (v) => (Math.round(v / 16) * 16) | 0;
const key = `${q(c.r)},${q(c.g)},${q(c.b)}|${shape}|${tex ? sigOf(tex[0]) : ''}|${variant}`;
let s = this.sprites.get(key);
if (s) return s;
if (this.sprites.size > 600) this.sprites.clear();
const S = 64;
s = document.createElement('canvas');
s.width = s.height = S;
const x = s.getContext('2d');
const rgb = `${q(c.r)},${q(c.g)},${q(c.b)}`;
if (shape === 'ring') {
x.strokeStyle = `rgb(${rgb})`; x.lineWidth = 5; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 6, 0, TAU); x.stroke();
} else if (shape === 'star') {
x.fillStyle = `rgb(${rgb})`; x.beginPath();
for (let i = 0; i < 8; i++) { const r = i % 2 ? S * 0.1 : S * 0.48, a = (i / 8) * TAU - Math.PI / 2; x.lineTo(S / 2 + Math.cos(a) * r, S / 2 + Math.sin(a) * r); }
x.closePath(); x.fill();
} else if (shape === 'square') {
x.fillStyle = `rgb(${rgb})`; x.fillRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7);
} else if (shape === 'heart' || shape === 'diamond' || shape === 'triangle' || shape === 'drop' || shape === 'plus') {
// the same polygons an aura can follow, filled
const pts = UNIT_SHAPES[shape === 'plus' ? 'cross' : shape];
x.fillStyle = `rgb(${rgb})`; x.beginPath();
pts.forEach(([px, py], i) => { const X = S * (0.08 + 0.84 * px), Y = S * (0.08 + 0.84 * py); if (i) x.lineTo(X, Y); else x.moveTo(X, Y); });
x.closePath(); x.fill();
} else if (shape === 'bubble') {
const g = x.createRadialGradient(S / 2, S / 2, S * 0.2, S / 2, S / 2, S / 2 - 3);
g.addColorStop(0, `rgba(${rgb},0.05)`); g.addColorStop(1, `rgba(${rgb},0.35)`);
x.fillStyle = g; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 3, 0, TAU); x.fill();
x.strokeStyle = `rgba(${rgb},0.9)`; x.lineWidth = 3; x.stroke();
x.strokeStyle = 'rgba(255,255,255,0.8)'; x.lineWidth = 4; x.lineCap = 'round'; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 12, Math.PI * 1.1, Math.PI * 1.45); x.stroke();
} else if (shape === 'snowflake') {
x.strokeStyle = `rgb(${rgb})`; x.lineWidth = 4; x.lineCap = 'round';
for (let i = 0; i < 6; i++) {
const a = (i / 6) * TAU, cx = Math.cos(a), cy = Math.sin(a);
x.beginPath(); x.moveTo(S / 2, S / 2); x.lineTo(S / 2 + cx * S * 0.44, S / 2 + cy * S * 0.44);
for (const f of [0.55, 0.8]) { // two little branches on each arm
const bx = S / 2 + cx * S * 0.44 * f, by = S / 2 + cy * S * 0.44 * f;
for (const d of [-0.9, 0.9]) { x.moveTo(bx, by); x.lineTo(bx + Math.cos(a + d) * S * 0.12, by + Math.sin(a + d) * S * 0.12); }
}
x.stroke();
}
} else if (shape === 'confetti') {
x.fillStyle = `rgb(${rgb})`; x.fillRect(S * 0.05, S * 0.05, S * 0.9, S * 0.9);
} else if (shape === 'leaf') {
x.fillStyle = `rgb(${rgb})`; x.beginPath(); x.moveTo(S * 0.12, S * 0.88); x.quadraticCurveTo(S * 0.08, S * 0.12, S * 0.9, S * 0.1); x.quadraticCurveTo(S * 0.92, S * 0.9, S * 0.12, S * 0.88); x.closePath(); x.fill();
x.strokeStyle = 'rgba(0,0,0,0.25)'; x.lineWidth = 2; x.beginPath(); x.moveTo(S * 0.15, S * 0.85); x.lineTo(S * 0.75, S * 0.25); x.stroke();
} else {
const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
const hard = shape === 'circle' ? 0.55 : 0.0;
g.addColorStop(0, `rgba(${rgb},1)`);
g.addColorStop(hard, `rgba(${rgb},${shape === 'circle' ? 0.95 : 0.7})`);
g.addColorStop(1, `rgba(${rgb},0)`);
x.fillStyle = g; x.fillRect(0, 0, S, S);
}
if (tex) {
// multiply the sprite's alpha by a texture mask, a different offset per variant
const m = this.maskCanvas(tex[0], 64);
const id = x.getImageData(0, 0, S, S);
const mx = m.getContext('2d').getImageData(0, 0, S, S).data;
const off = (variant * 13) % S;
for (let yy = 0; yy < S; yy++) for (let xx = 0; xx < S; xx++) {
const mi = (((yy + off) % S) * S + ((xx + off * 2) % S)) * 4 + 3;
const i = (yy * S + xx) * 4 + 3;
id.data[i] = (id.data[i] * mx[mi]) / 255;
}
x.putImageData(id, 0, 0);
}
this.sprites.set(key, s);
return s;
}
/* ---- particles ---- */
emit(L, rect, em, dt, share, el) {
const c = L.cfg;
const meanLife = ((c.life[0] + c.life[1]) / 2) / 1000;
const rate = (c.count * share) / meanLife; // per second
L.acc += rate * dt;
const cap = Math.max(1, Math.round(c.count * share));
while (L.acc >= 1) {
L.acc -= 1;
if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
L.parts.push(this.spawn(L, rect, el, em.scale || 1, em));
}
}
spawn(L, rect, el, scale = 1, em = null) {
const c = L.cfg, r = L.rnd;
const between = (a, b) => a + (b - a) * r();
let x, y;
switch (c.from) {
case 'bottom': x = rect.left + r() * rect.width; y = rect.bottom; break;
case 'top': x = rect.left + r() * rect.width; y = rect.top; break;
case 'left': x = rect.left; y = rect.top + r() * rect.height; break;
case 'right': x = rect.right; y = rect.top + r() * rect.height; break;
case 'center': x = rect.left + r() * rect.width; y = rect.top + r() * rect.height; break;
case 'corners': { const k = (r() * 4) | 0; x = k & 1 ? rect.right : rect.left; y = k & 2 ? rect.bottom : rect.top; break; }
case 'thumb': {
// the thumb of a range input: where the value is
let p = 0.5;
try { const mn = Number(el.min || 0), mx = Number(el.max || 100); p = (Number(el.value) - mn) / (mx - mn || 1); } catch { /* not a range */ }
const pad = 10;
x = rect.left + pad + p * Math.max(0, rect.width - pad * 2); y = rect.top + rect.height / 2 + (r() - 0.5) * 6; break;
}
default: { // edge / outline: a random point on the perimeter
// 0.16.0: along the element's REAL outline (turned, clipped to a diamond or a star), not the box around it
const ol = em && this.outlineOf(em, c);
if (ol && ol.total > 0) {
let d = r() * ol.total, i = 0;
while (i < ol.lens.length - 1 && d > ol.lens[i]) { d -= ol.lens[i]; i++; }
const p0 = ol.pts[i], p1 = ol.pts[(i + 1) % ol.pts.length], t = ol.lens[i] > 0 ? d / ol.lens[i] : 0;
x = p0.x + (p1.x - p0.x) * t; y = p0.y + (p1.y - p0.y) * t;
break;
}
const per = 2 * (rect.width + rect.height);
let d = r() * per;
if (d < rect.width) { x = rect.left + d; y = rect.top; }
else if ((d -= rect.width) < rect.height) { x = rect.right; y = rect.top + d; }
else if ((d -= rect.height) < rect.width) { x = rect.right - d; y = rect.bottom; }
else { d -= rect.width; x = rect.left; y = rect.bottom - d; }
}
}
let ang;
if (c.dir === 'out' || c.dir === 'in') {
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
ang = Math.atan2(y - cy, x - cx);
if (c.dir === 'in') ang += Math.PI;
} else ang = (c.dir * Math.PI) / 180;
ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
const sp = between(c.speed[0], c.speed[1]) * scale;
return {
x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
age: 0, life: between(c.life[0], c.life[1]) / 1000,
size: between(c.size[0], c.size[1]) * scale, rot: r() * TAU,
spin: c.spin !== undefined ? (r() < 0.5 ? -1 : 1) * ((c.spin * Math.PI) / 180) * (0.6 + 0.8 * r()) : (r() - 0.5) * 4,
variant: (r() * 4) | 0, ph: r() * TAU, pc: (r() * 1000) | 0,
};
}
stepAndDraw(ctx, L, dt, moving, hide) {
const c = L.cfg, parts = L.parts;
const additive = c.blend === 'add';
ctx.save();
if (hide) {
// clip the element's own shape OUT, so the layer reads as sitting behind it
ctx.beginPath();
ctx.rect(-50, -50, this.vw + 100, this.vh + 100);
this.addShape(ctx, hide.base, hide.geom);
ctx.clip('evenodd');
}
ctx.globalCompositeOperation = additive ? 'lighter' : 'source-over';
const drag = Math.exp(-c.drag * dt);
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (moving) {
p.age += dt;
if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
const sc = L.scale || 1;
p.vy += c.gravity * sc * dt;
if (c.wind) p.vx += c.wind * sc * dt;
if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * sc * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * sc * 0.3 * dt; }
if (c.drag) { p.vx *= drag; p.vy *= drag; }
p.x += p.vx * dt; p.y += p.vy * dt;
if (c.orbit && L.cx !== undefined) { // 0.16.0: circle the middle of the element
const ang = (c.orbit * Math.PI / 180) * dt, co = Math.cos(ang), si = Math.sin(ang), dx = p.x - L.cx, dy = p.y - L.cy;
p.x = L.cx + dx * co - dy * si; p.y = L.cy + dx * si + dy * co;
}
p.rot += p.spin * dt;
}
const t = p.age / p.life;
let col, alphaIn;
if (c.palette) {
// confetti: ONE colour per particle, fading in and out over its life
const base = c.stops[p.pc % Math.max(1, c.stops.length - (c.stops.length > 1 && c.stops[c.stops.length - 1].a === 0 ? 1 : 0))];
col = base; alphaIn = base.a * Math.min(1, t / 0.08, (1 - t) / 0.3);
} else { col = colorAt(c.stops, t); alphaIn = col.a; }
const alpha = clamp01(alphaIn) * (L.eo === undefined ? 1 : L.eo);
if (alpha <= 0.003) continue;
const size = p.size * lerp(1, c.grow, t);
this.drawParticle(ctx, c, p, col, alpha, size);
}
ctx.restore();
}
drawParticle(ctx, c, p, col, alpha, size) {
const shape = c.shape;
if (shape === 'spark' || shape === 'line') {
const len = Math.max(size, Math.hypot(p.vx, p.vy) * 0.06);
const sp = Math.hypot(p.vx, p.vy) || 1;
ctx.strokeStyle = `rgba(${col.r | 0},${col.g | 0},${col.b | 0},${alpha})`;
ctx.lineWidth = Math.max(1, size * 0.3);
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - (p.vx / sp) * len, p.y - (p.vy / sp) * len); ctx.stroke();
if (c.glowing) {
const s = this.sprite(col, 'glow', c.tex, p.variant), d = size * 3;
ctx.globalAlpha = alpha * 0.5; ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d); ctx.globalAlpha = 1;
}
return;
}
const kind = shape === 'circle' && c.glowing ? 'glow' : shape === 'flame' ? 'glow' : shape === 'smoke' ? 'glow' : shape;
const s = this.sprite(col, kind, c.tex, p.variant);
ctx.globalAlpha = alpha;
if (shape === 'flame') {
// a teardrop: stretched along its travel, narrower at the tip
const sp = Math.hypot(p.vx, p.vy) || 1;
const ang = Math.atan2(p.vy, p.vx) + Math.PI / 2;
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(ang);
const w = size * 2.2, h = size * 3.4;
ctx.drawImage(s, -w / 2, -h * 0.65, w, h);
ctx.restore();
} else if (shape === 'confetti') {
// a flat scrap tumbling: its height shrinks and grows as it turns over
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
const w = size * 1.5, h = Math.max(0.5, size * 0.8 * Math.abs(Math.cos(p.ph + p.age * 9)));
ctx.drawImage(s, -w / 2, -h / 2, w, h);
ctx.restore();
} else if (shape === 'ring' || shape === 'star' || shape === 'square' || shape === 'heart' || shape === 'diamond' || shape === 'triangle' || shape === 'plus' || shape === 'drop' || shape === 'bubble' || shape === 'snowflake' || shape === 'leaf') {
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(shape === 'drop' ? Math.atan2(p.vy, p.vx) + Math.PI / 2 : p.rot);
const d = size * (shape === 'ring' ? 1.2 : 1.4);
ctx.drawImage(s, -d / 2, -d / 2, d, d);
ctx.restore();
} else {
const d = size * (c.glowing || shape === 'smoke' ? 2.6 : 1.2);
ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d);
}
ctx.globalAlpha = 1;
}
/** for tests and tools: a readable snapshot, never needed in normal use */
debug() {
const out = [];
for (const [el, em] of this.emitters) out.push({ el, visible: em.visible, dying: em.dying, glow: em.glow.length, layers: em.layers.map((l) => ({ alive: l.parts.length, sample: l.parts.slice(0, 5).map((p) => ({ x: p.x, y: p.y, vx: p.vx, vy: p.vy, age: p.age })) })) });
return { stats: { ...this.stats }, running: this.running, paused: this.paused, canvas: !!this.canvas, emitters: out };
}
}