/* =========================================================================
* 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
or