|
|
// ============================================================
|
|
|
// SoulSync — Page Particle System
|
|
|
// Single canvas, per-page particle behaviors
|
|
|
// ============================================================
|
|
|
|
|
|
(function () {
|
|
|
'use strict';
|
|
|
|
|
|
// Disable particles on mobile devices for performance
|
|
|
if (window.innerWidth <= 768 || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return;
|
|
|
|
|
|
const canvas = document.getElementById('page-particles-canvas');
|
|
|
if (!canvas) return;
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
let animFrame = null;
|
|
|
let frameCount = 0;
|
|
|
let extras = {}; // temp ref swapped during initExtras calls
|
|
|
|
|
|
// Transition: converge → burst between presets
|
|
|
|
|
|
// ── Helpers ──
|
|
|
|
|
|
let _cachedAccent = '29, 185, 84';
|
|
|
let _accentCheckFrame = 0;
|
|
|
function getAccentRGB() {
|
|
|
// Only re-read CSS variable every 60 frames (~1s) to avoid getComputedStyle overhead
|
|
|
if (_accentCheckFrame++ % 60 === 0) {
|
|
|
const s = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim();
|
|
|
if (s) _cachedAccent = s;
|
|
|
}
|
|
|
return _cachedAccent;
|
|
|
}
|
|
|
|
|
|
// Shift an "r, g, b" accent string by a hue offset (degrees), cached per base color
|
|
|
let _shiftCache = { base: '', shifts: {} };
|
|
|
function shiftAccent(rgbStr, hueDeg) {
|
|
|
if (hueDeg === 0) return rgbStr;
|
|
|
// Check cache
|
|
|
if (_shiftCache.base !== rgbStr) _shiftCache = { base: rgbStr, shifts: {} };
|
|
|
const key = Math.round(hueDeg);
|
|
|
if (key in _shiftCache.shifts) return _shiftCache.shifts[key];
|
|
|
|
|
|
const [r, g, b] = rgbStr.split(',').map(s => parseInt(s.trim()));
|
|
|
const rn = r / 255, gn = g / 255, bn = b / 255;
|
|
|
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
|
|
|
let h = 0, s = 0;
|
|
|
const l = (max + min) / 2;
|
|
|
if (max !== min) {
|
|
|
const d = max - min;
|
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
|
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
|
|
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
|
|
else h = ((rn - gn) / d + 4) / 6;
|
|
|
}
|
|
|
h = ((h * 360 + hueDeg) % 360 + 360) % 360;
|
|
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
|
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
|
|
const m = l - c / 2;
|
|
|
let r1, g1, b1;
|
|
|
if (h < 60) { r1 = c; g1 = x; b1 = 0; }
|
|
|
else if (h < 120) { r1 = x; g1 = c; b1 = 0; }
|
|
|
else if (h < 180) { r1 = 0; g1 = c; b1 = x; }
|
|
|
else if (h < 240) { r1 = 0; g1 = x; b1 = c; }
|
|
|
else if (h < 300) { r1 = x; g1 = 0; b1 = c; }
|
|
|
else { r1 = c; g1 = 0; b1 = x; }
|
|
|
const result = `${Math.round((r1 + m) * 255)}, ${Math.round((g1 + m) * 255)}, ${Math.round((b1 + m) * 255)}`;
|
|
|
_shiftCache.shifts[key] = result;
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
function resize() {
|
|
|
const dpr = 1; // keep 1:1 for performance
|
|
|
canvas.width = canvas.clientWidth * dpr;
|
|
|
canvas.height = canvas.clientHeight * dpr;
|
|
|
}
|
|
|
|
|
|
window.addEventListener('resize', resize);
|
|
|
resize();
|
|
|
|
|
|
// ── Preset Definitions ──
|
|
|
// Each preset: { count, init(p, i), update(p, time, i), draw(p, ctx, accent, time),
|
|
|
// optional: initExtras(), drawGlobal(ctx, particles, accent, time, extras) }
|
|
|
|
|
|
const PRESETS = {
|
|
|
|
|
|
// ── DASHBOARD — network nodes, connections, data packets, shooting stars ──
|
|
|
dashboard: {
|
|
|
count: 50,
|
|
|
init(p, i) {
|
|
|
p.x = Math.random() * canvas.width;
|
|
|
p.y = Math.random() * canvas.height;
|
|
|
p.vx = (Math.random() - 0.5) * 0.3;
|
|
|
p.vy = (Math.random() - 0.5) * 0.3;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
// Hub nodes — ~10% are larger, brighter, attract more connections
|
|
|
p.isHub = i < 5;
|
|
|
p.radius = p.isHub ? (3 + Math.random() * 2) : (1.5 + Math.random() * 1.5);
|
|
|
// Color offset — hue shift ±30° from accent for variety
|
|
|
p.hueShift = (Math.random() - 0.5) * 60;
|
|
|
},
|
|
|
update(p) {
|
|
|
p.x += p.vx;
|
|
|
p.y += p.vy;
|
|
|
if (p.x < 15 || p.x > canvas.width - 15) p.vx *= -1;
|
|
|
if (p.y < 15 || p.y > canvas.height - 15) p.vy *= -1;
|
|
|
p.x = Math.max(5, Math.min(canvas.width - 5, p.x));
|
|
|
p.y = Math.max(5, Math.min(canvas.height - 5, p.y));
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 1.5 + p.phase);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
|
|
|
// Glow — hubs get bigger, brighter glow
|
|
|
const glowSize = p.isHub ? p.radius * 6 : p.radius * 4;
|
|
|
const glowAlpha = p.isHub ? (0.18 + pulse * 0.12) : (0.10 + pulse * 0.07);
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${glowAlpha})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Core
|
|
|
const coreAlpha = p.isHub ? (0.5 + pulse * 0.3) : (0.3 + pulse * 0.2);
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${coreAlpha})`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Hub ring
|
|
|
if (p.isHub) {
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius + 2 + pulse * 2, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${0.08 + pulse * 0.06})`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.packets = [];
|
|
|
extras.shootingStars = [];
|
|
|
extras.activeLines = {}; // "i-j" → glow remaining frames
|
|
|
extras.connectionDist = 180;
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.packets) { ex.packets = []; ex.shootingStars = []; ex.activeLines = {}; ex.connectionDist = 180; }
|
|
|
const dist = ex.connectionDist;
|
|
|
|
|
|
// Decay active line glows
|
|
|
for (const key in ex.activeLines) {
|
|
|
ex.activeLines[key] -= 0.02;
|
|
|
if (ex.activeLines[key] <= 0) delete ex.activeLines[key];
|
|
|
}
|
|
|
|
|
|
// Connections — hubs connect further, active lines glow brighter
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
for (let j = i + 1; j < parts.length; j++) {
|
|
|
const maxDist = (parts[i].isHub || parts[j].isHub) ? dist * 1.3 : dist;
|
|
|
const dx = parts[j].x - parts[i].x;
|
|
|
const dy = parts[j].y - parts[i].y;
|
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
|
if (d < maxDist) {
|
|
|
const key = `${i}-${j}`;
|
|
|
const baseAlpha = (1 - d / maxDist) * 0.10;
|
|
|
const glowBoost = ex.activeLines[key] || 0;
|
|
|
const lineCol = shiftAccent(accent, (parts[i].hueShift + parts[j].hueShift) * 0.5);
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(parts[i].x, parts[i].y);
|
|
|
ctx.lineTo(parts[j].x, parts[j].y);
|
|
|
ctx.strokeStyle = `rgba(${lineCol}, ${baseAlpha + glowBoost * 0.25})`;
|
|
|
ctx.lineWidth = glowBoost > 0 ? 1.2 : 0.7;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Spawn packets — prefer hub connections
|
|
|
if (frameCount % 60 === 0 && parts.length >= 2) {
|
|
|
// Pick a hub if possible, otherwise random
|
|
|
const hubs = parts.reduce((arr, p, i) => { if (p.isHub) arr.push(i); return arr; }, []);
|
|
|
const a = hubs.length > 0 && Math.random() < 0.6
|
|
|
? hubs[Math.floor(Math.random() * hubs.length)]
|
|
|
: Math.floor(Math.random() * parts.length);
|
|
|
let b = a, best = dist * 1.5;
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
if (i === a) continue;
|
|
|
const dx = parts[i].x - parts[a].x;
|
|
|
const dy = parts[i].y - parts[a].y;
|
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
|
if (d < best) { best = d; b = i; }
|
|
|
}
|
|
|
if (b !== a) {
|
|
|
ex.packets.push({ from: a, to: b, t: 0, trail: [], hue: parts[a].hueShift });
|
|
|
// Light up this connection
|
|
|
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
|
|
|
ex.activeLines[key] = 1;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Draw packets with comet trail
|
|
|
for (let i = ex.packets.length - 1; i >= 0; i--) {
|
|
|
const pkt = ex.packets[i];
|
|
|
pkt.t += 0.02;
|
|
|
if (pkt.t >= 1) { ex.packets.splice(i, 1); continue; }
|
|
|
const fa = parts[pkt.from], ta = parts[pkt.to];
|
|
|
const px = fa.x + (ta.x - fa.x) * pkt.t;
|
|
|
const py = fa.y + (ta.y - fa.y) * pkt.t;
|
|
|
|
|
|
// Store trail points
|
|
|
pkt.trail.push({ x: px, y: py });
|
|
|
if (pkt.trail.length > 8) pkt.trail.shift();
|
|
|
|
|
|
const col = shiftAccent(accent, pkt.hue);
|
|
|
|
|
|
// Draw trail
|
|
|
for (let t = 0; t < pkt.trail.length - 1; t++) {
|
|
|
const alpha = (t / pkt.trail.length) * 0.3;
|
|
|
const width = (t / pkt.trail.length) * 2;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(pkt.trail[t].x, pkt.trail[t].y);
|
|
|
ctx.lineTo(pkt.trail[t + 1].x, pkt.trail[t + 1].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha})`;
|
|
|
ctx.lineWidth = width;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Head glow
|
|
|
const g = ctx.createRadialGradient(px, py, 0, px, py, 8);
|
|
|
g.addColorStop(0, `rgba(${col}, 0.6)`);
|
|
|
g.addColorStop(0.4, `rgba(${col}, 0.15)`);
|
|
|
g.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(px, py, 8, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = g;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Bright core
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(px, py, 1.8, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
// Shooting stars — spawn occasionally
|
|
|
if (frameCount % 200 === 0 || (frameCount % 120 === 0 && Math.random() < 0.3)) {
|
|
|
const edge = Math.random();
|
|
|
let sx, sy, angle;
|
|
|
if (edge < 0.5) {
|
|
|
// From left/right
|
|
|
sx = edge < 0.25 ? -10 : canvas.width + 10;
|
|
|
sy = Math.random() * canvas.height * 0.6;
|
|
|
angle = edge < 0.25 ? (Math.random() * 0.4 + 0.1) : (Math.PI - Math.random() * 0.4 - 0.1);
|
|
|
} else {
|
|
|
// From top
|
|
|
sx = Math.random() * canvas.width;
|
|
|
sy = -10;
|
|
|
angle = Math.random() * 0.6 + 0.4 + (Math.random() < 0.5 ? 0 : Math.PI * 0.3);
|
|
|
}
|
|
|
const speed = 3 + Math.random() * 4;
|
|
|
ex.shootingStars.push({
|
|
|
x: sx, y: sy,
|
|
|
vx: Math.cos(angle) * speed,
|
|
|
vy: Math.sin(angle) * speed,
|
|
|
trail: [],
|
|
|
life: 1,
|
|
|
hue: (Math.random() - 0.5) * 80
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// Draw shooting stars
|
|
|
for (let i = ex.shootingStars.length - 1; i >= 0; i--) {
|
|
|
const star = ex.shootingStars[i];
|
|
|
star.x += star.vx;
|
|
|
star.y += star.vy;
|
|
|
star.life -= 0.012;
|
|
|
star.trail.push({ x: star.x, y: star.y });
|
|
|
if (star.trail.length > 20) star.trail.shift();
|
|
|
|
|
|
if (star.life <= 0 || star.x < -50 || star.x > canvas.width + 50 ||
|
|
|
star.y < -50 || star.y > canvas.height + 50) {
|
|
|
ex.shootingStars.splice(i, 1);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const col = shiftAccent(accent, star.hue);
|
|
|
|
|
|
// Trail
|
|
|
for (let t = 0; t < star.trail.length - 1; t++) {
|
|
|
const frac = t / star.trail.length;
|
|
|
const alpha = frac * 0.5 * star.life;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(star.trail[t].x, star.trail[t].y);
|
|
|
ctx.lineTo(star.trail[t + 1].x, star.trail[t + 1].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha})`;
|
|
|
ctx.lineWidth = frac * 2.5;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Head
|
|
|
const sg = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, 5);
|
|
|
sg.addColorStop(0, `rgba(255, 255, 255, ${0.8 * star.life})`);
|
|
|
sg.addColorStop(0.3, `rgba(${col}, ${0.5 * star.life})`);
|
|
|
sg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(star.x, star.y, 5, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = sg;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── SYNC — orbiting rings with sync arrows ──
|
|
|
sync: {
|
|
|
count: 40,
|
|
|
init(p, i) {
|
|
|
// Assign each particle to an orbital ring
|
|
|
const ringCount = 4;
|
|
|
p.ring = i % ringCount;
|
|
|
const ringRadii = [0.08, 0.15, 0.23, 0.32]; // fraction of min(w,h)
|
|
|
p.orbitRadius = ringRadii[p.ring];
|
|
|
p.angle = (i / Math.ceil(40 / ringCount)) * Math.PI * 2 + Math.random() * 0.3;
|
|
|
// Alternating directions per ring
|
|
|
p.speed = (p.ring % 2 === 0 ? 1 : -1) * (0.004 + p.ring * 0.002);
|
|
|
p.radius = 1.5 + Math.random() * 1.5;
|
|
|
p.hueShift = (p.ring - 1.5) * 25; // spread hues across rings
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
// Compute initial x/y
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const r = p.orbitRadius * Math.min(canvas.width, canvas.height);
|
|
|
p.x = cx + Math.cos(p.angle) * r;
|
|
|
p.y = cy + Math.sin(p.angle) * r * 0.5; // elliptical
|
|
|
},
|
|
|
update(p) {
|
|
|
p.angle += p.speed;
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const r = p.orbitRadius * Math.min(canvas.width, canvas.height);
|
|
|
p.x = cx + Math.cos(p.angle) * r;
|
|
|
p.y = cy + Math.sin(p.angle) * r * 0.5;
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 2 + p.phase);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
// Depth — particles "behind" center are dimmer
|
|
|
const depth = Math.sin(p.angle);
|
|
|
const depthAlpha = 0.3 + (depth * 0.5 + 0.5) * 0.5;
|
|
|
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius * 4);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${(0.12 + pulse * 0.08) * depthAlpha})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * 4, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (0.8 + depth * 0.2), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${(0.3 + pulse * 0.25) * depthAlpha})`;
|
|
|
ctx.fill();
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.syncArrowAngle = 0;
|
|
|
extras.transfers = []; // data pulses jumping between rings
|
|
|
extras.arcs = []; // energy arcs between particles
|
|
|
extras.transferTimer = 0;
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (ex.syncArrowAngle === undefined) {
|
|
|
ex.syncArrowAngle = 0; ex.transfers = []; ex.arcs = []; ex.transferTimer = 0;
|
|
|
}
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const minDim = Math.min(canvas.width, canvas.height);
|
|
|
const ringRadii = [0.08, 0.15, 0.23, 0.32];
|
|
|
|
|
|
// Draw faint orbital paths — pulsing
|
|
|
for (let r = 0; r < ringRadii.length; r++) {
|
|
|
const rx = ringRadii[r] * minDim;
|
|
|
const ry = rx * 0.5;
|
|
|
const col = shiftAccent(accent, (r - 1.5) * 25);
|
|
|
const ringPulse = 0.03 + 0.025 * Math.sin(time * 1.2 + r * 1.5);
|
|
|
ctx.beginPath();
|
|
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${ringPulse})`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Spawn data transfers between rings
|
|
|
ex.transferTimer++;
|
|
|
if (ex.transferTimer >= 50 + Math.floor(Math.random() * 30)) {
|
|
|
ex.transferTimer = 0;
|
|
|
// Pick two particles on different rings
|
|
|
const ringA = Math.floor(Math.random() * 4);
|
|
|
let ringB = ringA;
|
|
|
while (ringB === ringA) ringB = Math.floor(Math.random() * 4);
|
|
|
const fromParts = parts.filter(p => p.ring === ringA);
|
|
|
const toParts = parts.filter(p => p.ring === ringB);
|
|
|
if (fromParts.length && toParts.length) {
|
|
|
const from = fromParts[Math.floor(Math.random() * fromParts.length)];
|
|
|
const to = toParts[Math.floor(Math.random() * toParts.length)];
|
|
|
ex.transfers.push({
|
|
|
fx: from.x, fy: from.y,
|
|
|
tx: to.x, ty: to.y,
|
|
|
fromRef: from, toRef: to,
|
|
|
t: 0,
|
|
|
hue: (from.hueShift + to.hueShift) * 0.5,
|
|
|
trail: []
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Draw transfers — arc through center
|
|
|
for (let i = ex.transfers.length - 1; i >= 0; i--) {
|
|
|
const tr = ex.transfers[i];
|
|
|
tr.t += 0.025;
|
|
|
// Update endpoints to track moving particles
|
|
|
tr.fx = tr.fromRef.x; tr.fy = tr.fromRef.y;
|
|
|
tr.tx = tr.toRef.x; tr.ty = tr.toRef.y;
|
|
|
|
|
|
if (tr.t >= 1) { ex.transfers.splice(i, 1); continue; }
|
|
|
|
|
|
// Bezier through center
|
|
|
const t = tr.t;
|
|
|
const mt = 1 - t;
|
|
|
const px = mt * mt * tr.fx + 2 * mt * t * cx + t * t * tr.tx;
|
|
|
const py = mt * mt * tr.fy + 2 * mt * t * cy + t * t * tr.ty;
|
|
|
|
|
|
tr.trail.push({ x: px, y: py });
|
|
|
if (tr.trail.length > 8) tr.trail.shift();
|
|
|
|
|
|
const col = shiftAccent(accent, tr.hue);
|
|
|
|
|
|
// Trail
|
|
|
for (let s = 0; s < tr.trail.length - 1; s++) {
|
|
|
const frac = s / tr.trail.length;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(tr.trail[s].x, tr.trail[s].y);
|
|
|
ctx.lineTo(tr.trail[s + 1].x, tr.trail[s + 1].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${frac * 0.3})`;
|
|
|
ctx.lineWidth = frac * 2;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Head
|
|
|
const hg = ctx.createRadialGradient(px, py, 0, px, py, 6);
|
|
|
hg.addColorStop(0, `rgba(255,255,255, 0.5)`);
|
|
|
hg.addColorStop(0.3, `rgba(${col}, 0.3)`);
|
|
|
hg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(px, py, 6, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = hg;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
// Energy arcs — brief flickers between close particles on adjacent rings
|
|
|
if (frameCount % 8 === 0) {
|
|
|
// Clean old arcs
|
|
|
for (let i = ex.arcs.length - 1; i >= 0; i--) {
|
|
|
ex.arcs[i].life -= 0.08;
|
|
|
if (ex.arcs[i].life <= 0) ex.arcs.splice(i, 1);
|
|
|
}
|
|
|
// Maybe spawn new arc
|
|
|
if (ex.arcs.length < 3 && Math.random() < 0.3) {
|
|
|
const a = Math.floor(Math.random() * parts.length);
|
|
|
let bestDist = 120, bestIdx = -1;
|
|
|
for (let j = 0; j < parts.length; j++) {
|
|
|
if (j === a || parts[j].ring === parts[a].ring) continue;
|
|
|
if (Math.abs(parts[j].ring - parts[a].ring) > 1) continue;
|
|
|
const dx = parts[j].x - parts[a].x, dy = parts[j].y - parts[a].y;
|
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
|
if (d < bestDist) { bestDist = d; bestIdx = j; }
|
|
|
}
|
|
|
if (bestIdx >= 0) {
|
|
|
ex.arcs.push({ a, b: bestIdx, life: 1 });
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Draw arcs
|
|
|
for (const arc of ex.arcs) {
|
|
|
const pa = parts[arc.a], pb = parts[arc.b];
|
|
|
const col = shiftAccent(accent, (pa.hueShift + pb.hueShift) * 0.5);
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(pa.x, pa.y);
|
|
|
const mx = (pa.x + pb.x) * 0.5 + (Math.random() - 0.5) * 6;
|
|
|
const my = (pa.y + pb.y) * 0.5 + (Math.random() - 0.5) * 6;
|
|
|
ctx.quadraticCurveTo(mx, my, pb.x, pb.y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${arc.life * 0.2})`;
|
|
|
ctx.lineWidth = arc.life * 1.5;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
// Bright core
|
|
|
ctx.strokeStyle = `rgba(255,255,255, ${arc.life * 0.08})`;
|
|
|
ctx.lineWidth = arc.life * 0.5;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Center sync arrows — two curved arrows rotating
|
|
|
ex.syncArrowAngle += 0.008;
|
|
|
const arrowR = minDim * 0.035;
|
|
|
ctx.save();
|
|
|
ctx.translate(cx, cy);
|
|
|
ctx.rotate(ex.syncArrowAngle);
|
|
|
for (let a = 0; a < 2; a++) {
|
|
|
ctx.save();
|
|
|
ctx.rotate(a * Math.PI);
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(0, 0, arrowR, -0.9, 0.9);
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.15)`;
|
|
|
ctx.lineWidth = 1.5;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
// Arrowhead
|
|
|
const tx = arrowR * Math.cos(0.9), ty = arrowR * Math.sin(0.9);
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(tx - 4, ty - 3);
|
|
|
ctx.lineTo(tx, ty);
|
|
|
ctx.lineTo(tx + 1, ty - 5);
|
|
|
ctx.stroke();
|
|
|
ctx.restore();
|
|
|
}
|
|
|
ctx.restore();
|
|
|
|
|
|
// Center glow — pulses with transfer activity
|
|
|
const activity = Math.min(1, ex.transfers.length * 0.3);
|
|
|
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, minDim * 0.06);
|
|
|
cg.addColorStop(0, `rgba(${accent}, ${0.08 + activity * 0.1})`);
|
|
|
cg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, minDim * 0.06, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = cg;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── SEARCH — radar/sonar pulse with scanning particles ──
|
|
|
search: {
|
|
|
count: 45,
|
|
|
init(p, i) {
|
|
|
// Scatter particles across canvas
|
|
|
p.x = Math.random() * canvas.width;
|
|
|
p.y = Math.random() * canvas.height;
|
|
|
p.baseX = p.x;
|
|
|
p.baseY = p.y;
|
|
|
p.radius = 1 + Math.random() * 1.5;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (Math.random() - 0.5) * 50;
|
|
|
// Slow drift
|
|
|
p.vx = (Math.random() - 0.5) * 0.15;
|
|
|
p.vy = (Math.random() - 0.5) * 0.15;
|
|
|
// Lit state — brightens when radar sweeps over
|
|
|
p.lit = 0;
|
|
|
// Some particles are "result" nodes — slightly bigger, brighter
|
|
|
p.isResult = i < 8;
|
|
|
if (p.isResult) p.radius = 2 + Math.random() * 1.5;
|
|
|
},
|
|
|
update(p) {
|
|
|
// Gentle drift
|
|
|
p.x += p.vx;
|
|
|
p.y += p.vy;
|
|
|
// Soft bounds — bounce
|
|
|
if (p.x < 10 || p.x > canvas.width - 10) p.vx *= -1;
|
|
|
if (p.y < 10 || p.y > canvas.height - 10) p.vy *= -1;
|
|
|
p.x = Math.max(5, Math.min(canvas.width - 5, p.x));
|
|
|
p.y = Math.max(5, Math.min(canvas.height - 5, p.y));
|
|
|
// Decay lit state
|
|
|
if (p.lit > 0) p.lit = Math.max(0, p.lit - 0.015);
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 1.2 + p.phase);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
const litBoost = p.lit;
|
|
|
|
|
|
// Glow — bigger when lit
|
|
|
const glowSize = p.radius * (3 + litBoost * 5);
|
|
|
const glowAlpha = (p.isResult ? 0.08 : 0.05) + litBoost * 0.25;
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${glowAlpha})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Core
|
|
|
const coreAlpha = (p.isResult ? 0.25 : 0.15) + pulse * 0.1 + litBoost * 0.45;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (1 + litBoost * 0.3), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${coreAlpha})`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Result ring when lit
|
|
|
if (p.isResult && litBoost > 0.1) {
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius + 3 + litBoost * 4, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${litBoost * 0.2})`;
|
|
|
ctx.lineWidth = 0.6;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.rings = []; // expanding radar rings
|
|
|
extras.spawnTimer = 0;
|
|
|
extras.scanAngle = 0;
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.rings) { ex.rings = []; ex.spawnTimer = 0; ex.scanAngle = 0; }
|
|
|
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const maxRadius = Math.max(canvas.width, canvas.height) * 0.7;
|
|
|
|
|
|
// Spawn rings periodically
|
|
|
ex.spawnTimer++;
|
|
|
if (ex.spawnTimer >= 120) { // every ~2 seconds
|
|
|
ex.rings.push({
|
|
|
radius: 0,
|
|
|
maxRadius: maxRadius,
|
|
|
alpha: 0.35,
|
|
|
hue: (Math.random() - 0.5) * 40
|
|
|
});
|
|
|
ex.spawnTimer = 0;
|
|
|
}
|
|
|
|
|
|
// Update and draw rings
|
|
|
for (let i = ex.rings.length - 1; i >= 0; i--) {
|
|
|
const ring = ex.rings[i];
|
|
|
ring.radius += 2.5;
|
|
|
ring.alpha = 0.35 * (1 - ring.radius / ring.maxRadius);
|
|
|
|
|
|
if (ring.alpha <= 0 || ring.radius > ring.maxRadius) {
|
|
|
ex.rings.splice(i, 1);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const col = shiftAccent(accent, ring.hue);
|
|
|
|
|
|
// Ring stroke
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${ring.alpha * 0.5})`;
|
|
|
ctx.lineWidth = 1.5;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Soft inner glow on ring edge
|
|
|
const ringGlow = ctx.createRadialGradient(cx, cy, Math.max(0, ring.radius - 15), cx, cy, ring.radius + 5);
|
|
|
ringGlow.addColorStop(0, 'rgba(0,0,0,0)');
|
|
|
ringGlow.addColorStop(0.7, `rgba(${col}, ${ring.alpha * 0.08})`);
|
|
|
ringGlow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, ring.radius + 5, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = ringGlow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Light up particles near this ring's edge
|
|
|
for (const p of parts) {
|
|
|
const dx = p.x - cx, dy = p.y - cy;
|
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
const diff = Math.abs(dist - ring.radius);
|
|
|
if (diff < 25) {
|
|
|
p.lit = Math.max(p.lit, (1 - diff / 25) * ring.alpha * 2.5);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Rotating scan line — faint sweep
|
|
|
ex.scanAngle += 0.012;
|
|
|
const scanLen = maxRadius * 0.6;
|
|
|
const sx = cx + Math.cos(ex.scanAngle) * scanLen;
|
|
|
const sy = cy + Math.sin(ex.scanAngle) * scanLen;
|
|
|
const scanGrad = ctx.createLinearGradient(cx, cy, sx, sy);
|
|
|
scanGrad.addColorStop(0, `rgba(${accent}, 0.06)`);
|
|
|
scanGrad.addColorStop(0.7, `rgba(${accent}, 0.02)`);
|
|
|
scanGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(cx, cy);
|
|
|
ctx.lineTo(sx, sy);
|
|
|
ctx.strokeStyle = scanGrad;
|
|
|
ctx.lineWidth = 1;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Scan wedge — faint arc trailing the scan line
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(cx, cy);
|
|
|
ctx.arc(cx, cy, scanLen, ex.scanAngle - 0.3, ex.scanAngle, false);
|
|
|
ctx.closePath();
|
|
|
const wedgeGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, scanLen);
|
|
|
wedgeGrad.addColorStop(0, `rgba(${accent}, 0.03)`);
|
|
|
wedgeGrad.addColorStop(0.5, `rgba(${accent}, 0.01)`);
|
|
|
wedgeGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.fillStyle = wedgeGrad;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Center dot — radar origin
|
|
|
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 12);
|
|
|
cg.addColorStop(0, `rgba(${accent}, 0.15)`);
|
|
|
cg.addColorStop(0.5, `rgba(${accent}, 0.05)`);
|
|
|
cg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, 12, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = cg;
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${accent}, 0.3)`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Crosshair lines — very subtle
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.04)`;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(cx - scanLen * 0.4, cy);
|
|
|
ctx.lineTo(cx + scanLen * 0.4, cy);
|
|
|
ctx.moveTo(cx, cy - scanLen * 0.4);
|
|
|
ctx.lineTo(cx, cy + scanLen * 0.4);
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Range circles — faint concentric guides
|
|
|
for (let r = 1; r <= 3; r++) {
|
|
|
const guideR = scanLen * r * 0.3;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, guideR, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.025)`;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── DISCOVER — drifting constellation starfield ──
|
|
|
discover: {
|
|
|
count: 60,
|
|
|
init(p, i) {
|
|
|
p.x = Math.random() * canvas.width;
|
|
|
p.y = Math.random() * canvas.height;
|
|
|
// Slow parallax drift — different layers move at different speeds
|
|
|
p.layer = Math.random(); // 0 = far/dim, 1 = near/bright
|
|
|
p.vx = (0.05 + p.layer * 0.15) * (Math.random() < 0.5 ? 1 : -1);
|
|
|
p.vy = -0.02 - p.layer * 0.08; // gentle upward drift
|
|
|
p.radius = 0.5 + p.layer * 2;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (Math.random() - 0.5) * 70;
|
|
|
// Twinkle speed — far stars twinkle faster
|
|
|
p.twinkleSpeed = 1.5 + (1 - p.layer) * 2;
|
|
|
// Some are "constellation" anchor stars — brighter, connected
|
|
|
p.isAnchor = i < 15;
|
|
|
if (p.isAnchor) {
|
|
|
p.radius = 1.5 + p.layer * 2;
|
|
|
p.vx *= 0.4;
|
|
|
p.vy *= 0.4;
|
|
|
}
|
|
|
},
|
|
|
update(p) {
|
|
|
p.x += p.vx;
|
|
|
p.y += p.vy;
|
|
|
// Wrap around edges
|
|
|
if (p.x < -20) p.x = canvas.width + 20;
|
|
|
if (p.x > canvas.width + 20) p.x = -20;
|
|
|
if (p.y < -20) p.y = canvas.height + 20;
|
|
|
if (p.y > canvas.height + 20) p.y = -20;
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const twinkle = 0.4 + 0.6 * Math.pow(Math.sin(time * p.twinkleSpeed + p.phase), 2);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
const brightness = (0.3 + p.layer * 0.7) * twinkle;
|
|
|
|
|
|
// Soft glow
|
|
|
const glowR = p.radius * (3 + p.layer * 3);
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${brightness * 0.25})`);
|
|
|
glow.addColorStop(0.5, `rgba(${col}, ${brightness * 0.06})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Core star
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (0.6 + twinkle * 0.4), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${brightness})`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Cross-spike on bright anchor stars
|
|
|
if (p.isAnchor && twinkle > 0.7) {
|
|
|
const spikeLen = p.radius * 4 * twinkle;
|
|
|
const spikeAlpha = (twinkle - 0.7) * brightness * 0.5;
|
|
|
ctx.strokeStyle = `rgba(${col}, ${spikeAlpha})`;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(p.x - spikeLen, p.y);
|
|
|
ctx.lineTo(p.x + spikeLen, p.y);
|
|
|
ctx.moveTo(p.x, p.y - spikeLen);
|
|
|
ctx.lineTo(p.x, p.y + spikeLen);
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.constellations = []; // groups of connected anchor indices
|
|
|
extras.shootingStars = [];
|
|
|
extras.nebulae = [];
|
|
|
// Create 3 nebula regions
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
extras.nebulae.push({
|
|
|
x: Math.random() * canvas.width,
|
|
|
y: Math.random() * canvas.height,
|
|
|
radius: 80 + Math.random() * 120,
|
|
|
hue: (Math.random() - 0.5) * 80,
|
|
|
drift: (Math.random() - 0.5) * 0.15
|
|
|
});
|
|
|
}
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.nebulae) { ex.nebulae = []; ex.shootingStars = []; ex.constellations = []; }
|
|
|
|
|
|
// Draw nebula clouds — very subtle colored regions
|
|
|
for (const neb of ex.nebulae) {
|
|
|
neb.x += neb.drift;
|
|
|
if (neb.x < -200) neb.x = canvas.width + 200;
|
|
|
if (neb.x > canvas.width + 200) neb.x = -200;
|
|
|
|
|
|
const col = shiftAccent(accent, neb.hue);
|
|
|
const pulse = 0.7 + 0.3 * Math.sin(time * 0.3 + neb.hue);
|
|
|
const ng = ctx.createRadialGradient(neb.x, neb.y, 0, neb.x, neb.y, neb.radius);
|
|
|
ng.addColorStop(0, `rgba(${col}, ${0.035 * pulse})`);
|
|
|
ng.addColorStop(0.5, `rgba(${col}, ${0.015 * pulse})`);
|
|
|
ng.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(neb.x, neb.y, neb.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = ng;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
// Constellation lines — connect nearby anchor stars
|
|
|
const anchors = parts.filter(p => p.isAnchor);
|
|
|
const conDist = 180;
|
|
|
for (let i = 0; i < anchors.length; i++) {
|
|
|
for (let j = i + 1; j < anchors.length; j++) {
|
|
|
const dx = anchors[j].x - anchors[i].x;
|
|
|
const dy = anchors[j].y - anchors[i].y;
|
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
|
if (d < conDist) {
|
|
|
const alpha = (1 - d / conDist) * 0.08;
|
|
|
const col = shiftAccent(accent, (anchors[i].hueShift + anchors[j].hueShift) * 0.5);
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(anchors[i].x, anchors[i].y);
|
|
|
ctx.lineTo(anchors[j].x, anchors[j].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha})`;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Shooting stars — occasional streaks
|
|
|
if (frameCount % 180 === 0 || (frameCount % 100 === 0 && Math.random() < 0.25)) {
|
|
|
const sx = Math.random() * canvas.width;
|
|
|
const angle = Math.PI * 0.6 + Math.random() * 0.4;
|
|
|
ex.shootingStars.push({
|
|
|
x: sx, y: -10,
|
|
|
vx: Math.cos(angle) * (4 + Math.random() * 3),
|
|
|
vy: Math.sin(angle) * (4 + Math.random() * 3),
|
|
|
trail: [],
|
|
|
life: 1,
|
|
|
hue: (Math.random() - 0.5) * 60
|
|
|
});
|
|
|
}
|
|
|
|
|
|
for (let i = ex.shootingStars.length - 1; i >= 0; i--) {
|
|
|
const star = ex.shootingStars[i];
|
|
|
star.x += star.vx;
|
|
|
star.y += star.vy;
|
|
|
star.life -= 0.015;
|
|
|
star.trail.push({ x: star.x, y: star.y });
|
|
|
if (star.trail.length > 15) star.trail.shift();
|
|
|
|
|
|
if (star.life <= 0 || star.x < -50 || star.x > canvas.width + 50 ||
|
|
|
star.y > canvas.height + 50) {
|
|
|
ex.shootingStars.splice(i, 1);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const col = shiftAccent(accent, star.hue);
|
|
|
for (let t = 0; t < star.trail.length - 1; t++) {
|
|
|
const frac = t / star.trail.length;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(star.trail[t].x, star.trail[t].y);
|
|
|
ctx.lineTo(star.trail[t + 1].x, star.trail[t + 1].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${frac * 0.4 * star.life})`;
|
|
|
ctx.lineWidth = frac * 2;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
const sg = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, 4);
|
|
|
sg.addColorStop(0, `rgba(255,255,255, ${0.7 * star.life})`);
|
|
|
sg.addColorStop(0.4, `rgba(${col}, ${0.4 * star.life})`);
|
|
|
sg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(star.x, star.y, 4, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = sg;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── ARTISTS — sound ripples / audio wave field ──
|
|
|
artists: {
|
|
|
count: 50,
|
|
|
init(p, i) {
|
|
|
// Grid-ish scatter with jitter
|
|
|
const cols = 10, rows = 5;
|
|
|
const col = i % cols, row = Math.floor(i / cols);
|
|
|
p.baseX = (col + 0.5) / cols * canvas.width + (Math.random() - 0.5) * 40;
|
|
|
p.baseY = (row + 0.5) / rows * canvas.height + (Math.random() - 0.5) * 40;
|
|
|
p.x = p.baseX;
|
|
|
p.y = p.baseY;
|
|
|
p.radius = 1.2 + Math.random() * 1.3;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (Math.random() - 0.5) * 50;
|
|
|
p.displacement = 0; // vertical displacement from ripples
|
|
|
},
|
|
|
update(p, time) {
|
|
|
// Reset displacement each frame, drawGlobal will set it
|
|
|
p.displacement = 0;
|
|
|
p.x = p.baseX;
|
|
|
p.y = p.baseY;
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
const energy = Math.min(1, Math.abs(p.displacement));
|
|
|
const alpha = 0.15 + energy * 0.6;
|
|
|
const r = p.radius * (1 + energy * 0.5);
|
|
|
|
|
|
// Glow — stronger when displaced
|
|
|
const glowR = r * (3 + energy * 4);
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y + p.displacement * 15, 0, p.x, p.y + p.displacement * 15, glowR);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${0.06 + energy * 0.2})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y + p.displacement * 15, glowR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Core — offset by displacement
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y + p.displacement * 15, r, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${alpha})`;
|
|
|
ctx.fill();
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.emitters = [];
|
|
|
// 3–4 ripple emitters at random positions
|
|
|
const count = 3 + Math.floor(Math.random() * 2);
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
extras.emitters.push({
|
|
|
x: 0.15 * canvas.width + Math.random() * 0.7 * canvas.width,
|
|
|
y: 0.15 * canvas.height + Math.random() * 0.7 * canvas.height,
|
|
|
rings: [],
|
|
|
timer: Math.floor(Math.random() * 80),
|
|
|
interval: 70 + Math.floor(Math.random() * 40),
|
|
|
hue: (i - 1.5) * 35
|
|
|
});
|
|
|
}
|
|
|
extras.waveLines = [];
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.emitters) { ex.emitters = []; ex.waveLines = []; }
|
|
|
const maxR = Math.max(canvas.width, canvas.height) * 0.45;
|
|
|
|
|
|
// Update emitters — spawn rings (cap at 3 per emitter)
|
|
|
for (const em of ex.emitters) {
|
|
|
em.timer++;
|
|
|
if (em.timer >= em.interval && em.rings.length < 3) {
|
|
|
em.rings.push({ radius: 0, alpha: 0.3 });
|
|
|
em.timer = 0;
|
|
|
}
|
|
|
|
|
|
const col = shiftAccent(accent, em.hue);
|
|
|
|
|
|
// Draw emitter center — subtle pulsing dot
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 2 + em.hue);
|
|
|
const eg = ctx.createRadialGradient(em.x, em.y, 0, em.x, em.y, 15);
|
|
|
eg.addColorStop(0, `rgba(${col}, ${0.1 + pulse * 0.08})`);
|
|
|
eg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(em.x, em.y, 15, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = eg;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Update and draw rings
|
|
|
for (let i = em.rings.length - 1; i >= 0; i--) {
|
|
|
const ring = em.rings[i];
|
|
|
ring.radius += 1.8;
|
|
|
ring.alpha = 0.25 * (1 - ring.radius / maxR);
|
|
|
|
|
|
if (ring.alpha <= 0 || ring.radius > maxR) {
|
|
|
em.rings.splice(i, 1);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// Ring arc
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(em.x, em.y, ring.radius, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${ring.alpha * 0.4})`;
|
|
|
ctx.lineWidth = 1.5 * (1 - ring.radius / maxR);
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Displace particles near ring edge
|
|
|
for (const p of parts) {
|
|
|
const dx = p.baseX - em.x, dy = p.baseY - em.y;
|
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
const diff = Math.abs(dist - ring.radius);
|
|
|
if (diff < 30) {
|
|
|
const wave = Math.cos((diff / 30) * Math.PI * 0.5);
|
|
|
p.displacement += wave * ring.alpha * 1.8;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Horizontal wave lines — subtle sine waves across canvas
|
|
|
ctx.lineWidth = 0.4;
|
|
|
for (let w = 0; w < 3; w++) {
|
|
|
const yBase = canvas.height * (0.25 + w * 0.25);
|
|
|
const col = shiftAccent(accent, (w - 1) * 30);
|
|
|
ctx.beginPath();
|
|
|
for (let x = 0; x < canvas.width; x += 4) {
|
|
|
const y = yBase + Math.sin(x * 0.008 + time * (0.8 + w * 0.3) + w) * 12;
|
|
|
if (x === 0) ctx.moveTo(x, y);
|
|
|
else ctx.lineTo(x, y);
|
|
|
}
|
|
|
ctx.strokeStyle = `rgba(${col}, 0.04)`;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── AUTOMATIONS — electric / lightning circuit ──
|
|
|
automations: {
|
|
|
count: 40,
|
|
|
init(p, i) {
|
|
|
p.x = Math.random() * canvas.width;
|
|
|
p.y = Math.random() * canvas.height;
|
|
|
p.vx = (Math.random() - 0.5) * 0.25;
|
|
|
p.vy = (Math.random() - 0.5) * 0.25;
|
|
|
p.radius = 1 + Math.random() * 1.5;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (Math.random() - 0.5) * 40;
|
|
|
p.charge = 0; // lights up when struck by lightning
|
|
|
// Some are relay nodes — bigger, attract bolts
|
|
|
p.isRelay = i < 6;
|
|
|
if (p.isRelay) p.radius = 2.5 + Math.random() * 1.5;
|
|
|
},
|
|
|
update(p) {
|
|
|
p.x += p.vx;
|
|
|
p.y += p.vy;
|
|
|
if (p.x < 10 || p.x > canvas.width - 10) p.vx *= -1;
|
|
|
if (p.y < 10 || p.y > canvas.height - 10) p.vy *= -1;
|
|
|
p.x = Math.max(5, Math.min(canvas.width - 5, p.x));
|
|
|
p.y = Math.max(5, Math.min(canvas.height - 5, p.y));
|
|
|
if (p.charge > 0) p.charge = Math.max(0, p.charge - 0.02);
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 2 + p.phase);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
const energy = p.charge;
|
|
|
|
|
|
// Electric glow — bigger when charged
|
|
|
const glowR = p.radius * (3 + energy * 6);
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${(p.isRelay ? 0.12 : 0.06) + energy * 0.35})`);
|
|
|
glow.addColorStop(0.6, `rgba(${col}, ${energy * 0.08})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Core
|
|
|
const coreAlpha = (p.isRelay ? 0.3 : 0.15) + pulse * 0.1 + energy * 0.5;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (1 + energy * 0.3), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${coreAlpha})`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Relay ring
|
|
|
if (p.isRelay) {
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius + 3 + pulse * 2, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${0.06 + energy * 0.15})`;
|
|
|
ctx.lineWidth = 0.6;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.bolts = [];
|
|
|
extras.sparks = [];
|
|
|
extras.boltTimer = 0;
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.bolts) { ex.bolts = []; ex.sparks = []; ex.boltTimer = 0; }
|
|
|
|
|
|
// Spawn lightning bolts between relay nodes
|
|
|
ex.boltTimer++;
|
|
|
if (ex.boltTimer >= 45 + Math.floor(Math.random() * 30)) {
|
|
|
ex.boltTimer = 0;
|
|
|
const relays = [];
|
|
|
const normals = [];
|
|
|
parts.forEach((p, i) => { if (p.isRelay) relays.push(i); else normals.push(i); });
|
|
|
|
|
|
if (relays.length >= 2 || parts.length >= 2) {
|
|
|
let a, b;
|
|
|
if (relays.length >= 2 && Math.random() < 0.6) {
|
|
|
// Relay to relay
|
|
|
a = relays[Math.floor(Math.random() * relays.length)];
|
|
|
b = relays[Math.floor(Math.random() * relays.length)];
|
|
|
if (a === b) b = relays[(relays.indexOf(a) + 1) % relays.length];
|
|
|
} else {
|
|
|
// Relay to nearest normal
|
|
|
a = relays.length > 0 ? relays[Math.floor(Math.random() * relays.length)] : Math.floor(Math.random() * parts.length);
|
|
|
let bestD = Infinity;
|
|
|
b = a;
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
if (i === a) continue;
|
|
|
const dx = parts[i].x - parts[a].x, dy = parts[i].y - parts[a].y;
|
|
|
const d = dx * dx + dy * dy;
|
|
|
if (d < bestD && d < 300 * 300) { bestD = d; b = i; }
|
|
|
}
|
|
|
}
|
|
|
if (a !== b) {
|
|
|
// Build jagged bolt path
|
|
|
const pa = parts[a], pb = parts[b];
|
|
|
const segs = 6 + Math.floor(Math.random() * 4);
|
|
|
const path = [{ x: pa.x, y: pa.y }];
|
|
|
for (let s = 1; s < segs; s++) {
|
|
|
const t = s / segs;
|
|
|
const mx = pa.x + (pb.x - pa.x) * t;
|
|
|
const my = pa.y + (pb.y - pa.y) * t;
|
|
|
const jitter = 25 * (1 - Math.abs(t - 0.5) * 2);
|
|
|
path.push({
|
|
|
x: mx + (Math.random() - 0.5) * jitter * 2,
|
|
|
y: my + (Math.random() - 0.5) * jitter * 2
|
|
|
});
|
|
|
}
|
|
|
path.push({ x: pb.x, y: pb.y });
|
|
|
ex.bolts.push({
|
|
|
path,
|
|
|
life: 1,
|
|
|
hue: (parts[a].hueShift + parts[b].hueShift) * 0.5,
|
|
|
branch: Math.random() < 0.4 // some bolts branch
|
|
|
});
|
|
|
parts[a].charge = 1;
|
|
|
parts[b].charge = 1;
|
|
|
|
|
|
// Sparks at endpoints
|
|
|
for (let s = 0; s < 4; s++) {
|
|
|
ex.sparks.push({
|
|
|
x: pb.x, y: pb.y,
|
|
|
vx: (Math.random() - 0.5) * 3,
|
|
|
vy: (Math.random() - 0.5) * 3,
|
|
|
life: 0.6 + Math.random() * 0.4,
|
|
|
hue: parts[b].hueShift
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Draw bolts
|
|
|
for (let i = ex.bolts.length - 1; i >= 0; i--) {
|
|
|
const bolt = ex.bolts[i];
|
|
|
bolt.life -= 0.04;
|
|
|
if (bolt.life <= 0) { ex.bolts.splice(i, 1); continue; }
|
|
|
|
|
|
const col = shiftAccent(accent, bolt.hue);
|
|
|
const alpha = bolt.life;
|
|
|
|
|
|
// Main bolt
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(bolt.path[0].x, bolt.path[0].y);
|
|
|
for (let s = 1; s < bolt.path.length; s++) {
|
|
|
ctx.lineTo(bolt.path[s].x, bolt.path[s].y);
|
|
|
}
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha * 0.6})`;
|
|
|
ctx.lineWidth = 2 * alpha;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.lineJoin = 'round';
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Bright core
|
|
|
ctx.strokeStyle = `rgba(255,255,255, ${alpha * 0.4})`;
|
|
|
ctx.lineWidth = 0.8 * alpha;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Glow along bolt
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha * 0.15})`;
|
|
|
ctx.lineWidth = 6 * alpha;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Branch bolt
|
|
|
if (bolt.branch && bolt.path.length > 3) {
|
|
|
const branchIdx = 2 + Math.floor(Math.random() * (bolt.path.length - 3));
|
|
|
const bp = bolt.path[branchIdx];
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(bp.x, bp.y);
|
|
|
let bx = bp.x, by = bp.y;
|
|
|
for (let s = 0; s < 3; s++) {
|
|
|
bx += (Math.random() - 0.5) * 30;
|
|
|
by += (Math.random() - 0.5) * 30;
|
|
|
ctx.lineTo(bx, by);
|
|
|
}
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha * 0.3})`;
|
|
|
ctx.lineWidth = 1 * alpha;
|
|
|
ctx.stroke();
|
|
|
bolt.branch = false; // only draw branch once
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Draw sparks
|
|
|
for (let i = ex.sparks.length - 1; i >= 0; i--) {
|
|
|
const sp = ex.sparks[i];
|
|
|
sp.x += sp.vx;
|
|
|
sp.y += sp.vy;
|
|
|
sp.vx *= 0.95;
|
|
|
sp.vy *= 0.95;
|
|
|
sp.life -= 0.03;
|
|
|
if (sp.life <= 0) { ex.sparks.splice(i, 1); continue; }
|
|
|
|
|
|
const col = shiftAccent(accent, sp.hue);
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(sp.x, sp.y, 1.2 * sp.life, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${sp.life * 0.8})`;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
// Ambient circuit traces — faint grid lines
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.015)`;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
const spacing = 80;
|
|
|
for (let x = spacing; x < canvas.width; x += spacing) {
|
|
|
const wobble = Math.sin(x * 0.01 + time * 0.5) * 5;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(x + wobble, 0);
|
|
|
ctx.lineTo(x - wobble, canvas.height);
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
for (let y = spacing; y < canvas.height; y += spacing) {
|
|
|
const wobble = Math.cos(y * 0.01 + time * 0.5) * 5;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(0, y + wobble);
|
|
|
ctx.lineTo(canvas.width, y - wobble);
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── LIBRARY — vinyl turntable grooves ──
|
|
|
library: {
|
|
|
count: 50,
|
|
|
init(p, i) {
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.5;
|
|
|
const minDim = Math.min(canvas.width, canvas.height);
|
|
|
// Distribute across grooves — 8 concentric rings
|
|
|
const grooveCount = 8;
|
|
|
p.groove = i % grooveCount;
|
|
|
p.grooveRadius = (0.06 + p.groove * 0.045) * minDim;
|
|
|
p.angle = (i / Math.ceil(50 / grooveCount)) * Math.PI * 2 + Math.random() * 0.5;
|
|
|
// All rotate same direction (clockwise), outer grooves slightly slower
|
|
|
p.speed = 0.003 - p.groove * 0.0002;
|
|
|
p.radius = 1 + Math.random() * 1.2;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (p.groove - 3.5) * 12;
|
|
|
p.x = cx + Math.cos(p.angle) * p.grooveRadius;
|
|
|
p.y = cy + Math.sin(p.angle) * p.grooveRadius;
|
|
|
},
|
|
|
update(p) {
|
|
|
p.angle += p.speed;
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.5;
|
|
|
p.x = cx + Math.cos(p.angle) * p.grooveRadius;
|
|
|
p.y = cy + Math.sin(p.angle) * p.grooveRadius;
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 1.5 + p.phase);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
|
|
|
const glowR = p.radius * 4;
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${0.1 + pulse * 0.08})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${0.25 + pulse * 0.2})`;
|
|
|
ctx.fill();
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.tonearmAngle = -0.4;
|
|
|
extras.spinHighlight = 0;
|
|
|
extras.needleRipples = [];
|
|
|
extras.rippleTimer = 0;
|
|
|
extras.noteParticles = []; // tiny notes floating off the needle
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (ex.tonearmAngle === undefined) {
|
|
|
ex.tonearmAngle = -0.4; ex.spinHighlight = 0;
|
|
|
ex.needleRipples = []; ex.rippleTimer = 0; ex.noteParticles = [];
|
|
|
}
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.5;
|
|
|
const minDim = Math.min(canvas.width, canvas.height);
|
|
|
|
|
|
// Vinyl grooves — concentric rings with subtle wobble
|
|
|
const grooveCount = 8;
|
|
|
for (let g = 0; g < grooveCount; g++) {
|
|
|
const r = (0.06 + g * 0.045) * minDim;
|
|
|
const col = shiftAccent(accent, (g - 3.5) * 12);
|
|
|
// Groove brightness pulses slightly
|
|
|
const gPulse = 0.03 + 0.015 * Math.sin(time * 0.8 + g * 0.7);
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${gPulse})`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Outer rim
|
|
|
const outerR = (0.06 + grooveCount * 0.045 + 0.02) * minDim;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, outerR, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.06)`;
|
|
|
ctx.lineWidth = 1.5;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Center label — warm glow
|
|
|
const labelR = 0.04 * minDim;
|
|
|
const lg = ctx.createRadialGradient(cx, cy, 0, cx, cy, labelR);
|
|
|
lg.addColorStop(0, `rgba(${accent}, 0.15)`);
|
|
|
lg.addColorStop(0.6, `rgba(${accent}, 0.06)`);
|
|
|
lg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, labelR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = lg;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Spindle dot
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, 2.5, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${accent}, 0.3)`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Light reflection — rotating highlight arc
|
|
|
ex.spinHighlight += 0.006;
|
|
|
const hlAngle = ex.spinHighlight;
|
|
|
const hlR = 0.22 * minDim;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, hlR, hlAngle - 0.4, hlAngle + 0.4);
|
|
|
const hlGrad = ctx.createRadialGradient(
|
|
|
cx + Math.cos(hlAngle) * hlR * 0.5,
|
|
|
cy + Math.sin(hlAngle) * hlR * 0.5, 0,
|
|
|
cx + Math.cos(hlAngle) * hlR * 0.5,
|
|
|
cy + Math.sin(hlAngle) * hlR * 0.5, hlR * 0.6
|
|
|
);
|
|
|
hlGrad.addColorStop(0, `rgba(255,255,255, 0.03)`);
|
|
|
hlGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.strokeStyle = hlGrad;
|
|
|
ctx.lineWidth = hlR * 0.8;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Tonearm — pivot at top-right, needle rests on groove 5
|
|
|
const armPivotX = cx + outerR * 0.9;
|
|
|
const armPivotY = cy - outerR * 0.75;
|
|
|
const needleGroove = 5;
|
|
|
const needleR = (0.06 + needleGroove * 0.045) * minDim;
|
|
|
// Needle sits on the groove at a fixed angle from center
|
|
|
const needleAngle = -0.6 + Math.sin(time * 0.3) * 0.01; // subtle bob
|
|
|
const armEndX = cx + Math.cos(needleAngle) * needleR;
|
|
|
const armEndY = cy + Math.sin(needleAngle) * needleR;
|
|
|
|
|
|
// Arm line — straight from pivot to needle
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(armPivotX, armPivotY);
|
|
|
ctx.lineTo(armEndX, armEndY);
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.1)`;
|
|
|
ctx.lineWidth = 1.8;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Headshell — small wider section at the end
|
|
|
const hsLen = 12;
|
|
|
const hsDx = (armEndX - armPivotX), hsDy = (armEndY - armPivotY);
|
|
|
const hsNorm = Math.sqrt(hsDx * hsDx + hsDy * hsDy);
|
|
|
const hsX = armEndX - (hsDx / hsNorm) * hsLen;
|
|
|
const hsY = armEndY - (hsDy / hsNorm) * hsLen;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(hsX, hsY);
|
|
|
ctx.lineTo(armEndX, armEndY);
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.15)`;
|
|
|
ctx.lineWidth = 3;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Pivot dot
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(armPivotX, armPivotY, 3, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${accent}, 0.12)`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Needle glow — on the groove
|
|
|
const ng = ctx.createRadialGradient(armEndX, armEndY, 0, armEndX, armEndY, 8);
|
|
|
ng.addColorStop(0, `rgba(${accent}, 0.3)`);
|
|
|
ng.addColorStop(0.5, `rgba(${accent}, 0.1)`);
|
|
|
ng.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(armEndX, armEndY, 8, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = ng;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Sound wave ripples — concentric arcs expanding FROM the needle outward
|
|
|
ex.rippleTimer++;
|
|
|
if (ex.rippleTimer >= 30) {
|
|
|
ex.rippleTimer = 0;
|
|
|
ex.needleRipples.push({
|
|
|
radius: 0, alpha: 0.3,
|
|
|
hue: (Math.random() - 0.5) * 30
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const maxRippleR = outerR * 0.7;
|
|
|
for (let i = ex.needleRipples.length - 1; i >= 0; i--) {
|
|
|
const rip = ex.needleRipples[i];
|
|
|
rip.radius += 1.8;
|
|
|
rip.alpha = 0.25 * (1 - rip.radius / maxRippleR);
|
|
|
if (rip.alpha <= 0 || rip.radius > maxRippleR) {
|
|
|
ex.needleRipples.splice(i, 1);
|
|
|
continue;
|
|
|
}
|
|
|
const col = shiftAccent(accent, rip.hue);
|
|
|
// Full circle ripple centered on needle
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(armEndX, armEndY, rip.radius, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${rip.alpha * 0.35})`;
|
|
|
ctx.lineWidth = 1 * (1 - rip.radius / maxRippleR);
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Brighten groove particles near ripple edge
|
|
|
for (const p of parts) {
|
|
|
const dx = p.x - armEndX, dy = p.y - armEndY;
|
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
if (Math.abs(dist - rip.radius) < 15) {
|
|
|
p.phase = time * 1.5;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Floating note particles — tiny shapes drifting from needle area
|
|
|
if (frameCount % 60 === 0) {
|
|
|
ex.noteParticles.push({
|
|
|
x: armEndX + (Math.random() - 0.5) * 15,
|
|
|
y: armEndY,
|
|
|
vx: (Math.random() - 0.5) * 0.8,
|
|
|
vy: -0.5 - Math.random() * 0.8,
|
|
|
life: 1,
|
|
|
size: 3 + Math.random() * 3,
|
|
|
hue: (Math.random() - 0.5) * 40,
|
|
|
type: Math.floor(Math.random() * 2) // 0 = eighth note, 1 = beam pair
|
|
|
});
|
|
|
}
|
|
|
|
|
|
for (let i = ex.noteParticles.length - 1; i >= 0; i--) {
|
|
|
const n = ex.noteParticles[i];
|
|
|
n.x += n.vx;
|
|
|
n.y += n.vy;
|
|
|
n.vx += (Math.random() - 0.5) * 0.02;
|
|
|
n.life -= 0.008;
|
|
|
if (n.life <= 0) { ex.noteParticles.splice(i, 1); continue; }
|
|
|
|
|
|
const col = shiftAccent(accent, n.hue);
|
|
|
const a = n.life * 0.3;
|
|
|
const s = n.size;
|
|
|
|
|
|
if (n.type === 0) {
|
|
|
// Eighth note — circle head + stem + flag
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(n.x, n.y, s * 0.35, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${a})`;
|
|
|
ctx.fill();
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(n.x + s * 0.35, n.y);
|
|
|
ctx.lineTo(n.x + s * 0.35, n.y - s);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${a})`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
// Flag
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(n.x + s * 0.35, n.y - s);
|
|
|
ctx.quadraticCurveTo(n.x + s * 0.8, n.y - s * 0.6, n.x + s * 0.35, n.y - s * 0.4);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${a * 0.8})`;
|
|
|
ctx.stroke();
|
|
|
} else {
|
|
|
// Beamed pair — two note heads connected
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(n.x, n.y, s * 0.3, 0, Math.PI * 2);
|
|
|
ctx.arc(n.x + s * 0.6, n.y, s * 0.3, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${a})`;
|
|
|
ctx.fill();
|
|
|
// Stems
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(n.x + s * 0.3, n.y);
|
|
|
ctx.lineTo(n.x + s * 0.3, n.y - s * 0.9);
|
|
|
ctx.moveTo(n.x + s * 0.9, n.y);
|
|
|
ctx.lineTo(n.x + s * 0.9, n.y - s * 0.9);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${a})`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
// Beam
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(n.x + s * 0.3, n.y - s * 0.9);
|
|
|
ctx.lineTo(n.x + s * 0.9, n.y - s * 0.9);
|
|
|
ctx.lineWidth = 1.2;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── IMPORT — data stream flowing into a central vortex ──
|
|
|
import: {
|
|
|
count: 55,
|
|
|
init(p, i) {
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
// Spawn from edges, will flow toward center
|
|
|
p.radius = 0.8 + Math.random() * 1.3;
|
|
|
p.hueShift = (Math.random() - 0.5) * 50;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.speed = 0.8 + Math.random() * 1.2;
|
|
|
p.absorbed = false;
|
|
|
// Stream column — particles fall in vertical streams
|
|
|
p.stream = i % 7;
|
|
|
p.streamX = canvas.width * (0.1 + (p.stream / 6) * 0.8);
|
|
|
// Stagger vertically
|
|
|
p.x = p.streamX + (Math.random() - 0.5) * 20;
|
|
|
p.y = -Math.random() * canvas.height;
|
|
|
// Pull radius — when close to center, spiral in
|
|
|
p.pullDist = 120 + Math.random() * 60;
|
|
|
p.spiralAngle = Math.atan2(p.y - cy, p.x - cx);
|
|
|
p.trail = [];
|
|
|
},
|
|
|
update(p) {
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const dx = cx - p.x, dy = cy - p.y;
|
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
if (dist < p.pullDist) {
|
|
|
// Spiral inward
|
|
|
p.spiralAngle += 0.08;
|
|
|
const pullStrength = 1 - dist / p.pullDist;
|
|
|
const spiralR = dist * (1 - pullStrength * 0.04);
|
|
|
p.x = cx + Math.cos(p.spiralAngle) * spiralR;
|
|
|
p.y = cy + Math.sin(p.spiralAngle) * spiralR;
|
|
|
|
|
|
if (dist < 8) {
|
|
|
// Absorbed — reset from top
|
|
|
p.x = p.streamX + (Math.random() - 0.5) * 20;
|
|
|
p.y = -10 - Math.random() * 100;
|
|
|
p.spiralAngle = Math.atan2(p.y - cy, p.x - cx);
|
|
|
p.trail = [];
|
|
|
}
|
|
|
} else {
|
|
|
// Fall downward in stream
|
|
|
p.x += Math.sin(p.phase + p.y * 0.005) * 0.3;
|
|
|
p.y += p.speed;
|
|
|
// Update spiral angle as we approach
|
|
|
p.spiralAngle = Math.atan2(p.y - cy, p.x - cx);
|
|
|
}
|
|
|
|
|
|
// Trail
|
|
|
p.trail.push({ x: p.x, y: p.y });
|
|
|
if (p.trail.length > 6) p.trail.shift();
|
|
|
|
|
|
// Reset if off screen
|
|
|
if (p.y > canvas.height + 30 && dist > p.pullDist) {
|
|
|
p.x = p.streamX + (Math.random() - 0.5) * 20;
|
|
|
p.y = -10 - Math.random() * 50;
|
|
|
p.trail = [];
|
|
|
}
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const dist = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2);
|
|
|
const nearCenter = Math.max(0, 1 - dist / p.pullDist);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 2 + p.phase);
|
|
|
|
|
|
// Trail
|
|
|
for (let t = 0; t < p.trail.length - 1; t++) {
|
|
|
const frac = t / p.trail.length;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(p.trail[t].x, p.trail[t].y);
|
|
|
ctx.lineTo(p.trail[t + 1].x, p.trail[t + 1].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${frac * 0.15 * (1 + nearCenter)})`;
|
|
|
ctx.lineWidth = frac * 1.5;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Glow — intensifies near center
|
|
|
const glowR = p.radius * (3 + nearCenter * 4);
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${0.08 + nearCenter * 0.25 + pulse * 0.05})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Core
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (1 + nearCenter * 0.5), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${0.2 + nearCenter * 0.5 + pulse * 0.1})`;
|
|
|
ctx.fill();
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.portalPulse = 0;
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (ex.portalPulse === undefined) ex.portalPulse = 0;
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.45;
|
|
|
const minDim = Math.min(canvas.width, canvas.height);
|
|
|
|
|
|
// Portal at center — pulsing vortex
|
|
|
ex.portalPulse += 0.02;
|
|
|
|
|
|
// Concentric portal rings
|
|
|
for (let r = 0; r < 4; r++) {
|
|
|
const radius = 10 + r * 12;
|
|
|
const rot = time * (1.5 - r * 0.3) * (r % 2 === 0 ? 1 : -1);
|
|
|
const alpha = 0.08 - r * 0.015;
|
|
|
const col = shiftAccent(accent, r * 20 - 30);
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, radius, rot, rot + Math.PI * 1.5);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${alpha})`;
|
|
|
ctx.lineWidth = 1.5 - r * 0.2;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Center glow — pulses with absorptions
|
|
|
const gPulse = 0.8 + 0.2 * Math.sin(ex.portalPulse * 3);
|
|
|
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 40);
|
|
|
cg.addColorStop(0, `rgba(${accent}, ${0.15 * gPulse})`);
|
|
|
cg.addColorStop(0.4, `rgba(${accent}, ${0.05 * gPulse})`);
|
|
|
cg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, 40, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = cg;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Stream guide lines — faint vertical lines showing data streams
|
|
|
for (let s = 0; s < 7; s++) {
|
|
|
const sx = canvas.width * (0.1 + (s / 6) * 0.8);
|
|
|
const col = shiftAccent(accent, (s - 3) * 15);
|
|
|
|
|
|
// Fade line from top toward center
|
|
|
const grad = ctx.createLinearGradient(sx, 0, sx, cy);
|
|
|
grad.addColorStop(0, `rgba(${col}, 0.03)`);
|
|
|
grad.addColorStop(0.7, `rgba(${col}, 0.015)`);
|
|
|
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(sx, 0);
|
|
|
// Curve toward center
|
|
|
ctx.quadraticCurveTo(sx, cy * 0.7, cx, cy);
|
|
|
ctx.strokeStyle = grad;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Outer absorption ring — faint
|
|
|
const absR = 120 + Math.sin(time * 0.8) * 10;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, absR, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${accent}, 0.025)`;
|
|
|
ctx.lineWidth = 0.5;
|
|
|
ctx.setLineDash([4, 8]);
|
|
|
ctx.stroke();
|
|
|
ctx.setLineDash([]);
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── SETTINGS — interlocking gears / clockwork ──
|
|
|
settings: {
|
|
|
count: 45,
|
|
|
init(p, i) {
|
|
|
// Assign particles to gears
|
|
|
const gearDefs = [
|
|
|
{ cx: 0.35, cy: 0.4, r: 0.16, teeth: 12, dir: 1 },
|
|
|
{ cx: 0.62, cy: 0.38, r: 0.13, teeth: 10, dir: -1 },
|
|
|
{ cx: 0.48, cy: 0.65, r: 0.10, teeth: 8, dir: 1 },
|
|
|
{ cx: 0.25, cy: 0.68, r: 0.08, teeth: 6, dir: -1 },
|
|
|
{ cx: 0.72, cy: 0.62, r: 0.09, teeth: 7, dir: 1 },
|
|
|
];
|
|
|
p.gear = i % gearDefs.length;
|
|
|
const g = gearDefs[p.gear];
|
|
|
const minDim = Math.min(canvas.width, canvas.height);
|
|
|
p.gearCx = g.cx * canvas.width;
|
|
|
p.gearCy = g.cy * canvas.height;
|
|
|
p.gearR = g.r * minDim;
|
|
|
p.gearTeeth = g.teeth;
|
|
|
p.gearDir = g.dir;
|
|
|
// Position along gear rim
|
|
|
p.angle = (i / Math.ceil(45 / gearDefs.length)) * Math.PI * 2 + Math.random() * 0.3;
|
|
|
p.speed = g.dir * (0.003 + (1 / g.teeth) * 0.008);
|
|
|
p.radius = 1 + Math.random() * 1.2;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (p.gear - 2) * 20;
|
|
|
p.x = p.gearCx + Math.cos(p.angle) * p.gearR;
|
|
|
p.y = p.gearCy + Math.sin(p.angle) * p.gearR;
|
|
|
},
|
|
|
update(p) {
|
|
|
p.angle += p.speed;
|
|
|
// Tooth bump — particle radius wobbles with gear teeth
|
|
|
const toothPhase = (p.angle * p.gearTeeth) % (Math.PI * 2);
|
|
|
p.toothBump = Math.max(0, Math.sin(toothPhase)) * 0.15;
|
|
|
const r = p.gearR * (1 + p.toothBump);
|
|
|
p.x = p.gearCx + Math.cos(p.angle) * r;
|
|
|
p.y = p.gearCy + Math.sin(p.angle) * r;
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(time * 1.5 + p.phase);
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
const bump = p.toothBump || 0;
|
|
|
|
|
|
const glowR = p.radius * (3 + bump * 6);
|
|
|
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
|
|
glow.addColorStop(0, `rgba(${col}, ${0.1 + bump * 0.2 + pulse * 0.05})`);
|
|
|
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = glow;
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (1 + bump * 0.5), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${0.2 + bump * 0.35 + pulse * 0.1})`;
|
|
|
ctx.fill();
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.gearDefs = [
|
|
|
{ cx: 0.35, cy: 0.4, r: 0.16, teeth: 12, dir: 1 },
|
|
|
{ cx: 0.62, cy: 0.38, r: 0.13, teeth: 10, dir: -1 },
|
|
|
{ cx: 0.48, cy: 0.65, r: 0.10, teeth: 8, dir: 1 },
|
|
|
{ cx: 0.25, cy: 0.68, r: 0.08, teeth: 6, dir: -1 },
|
|
|
{ cx: 0.72, cy: 0.62, r: 0.09, teeth: 7, dir: 1 },
|
|
|
];
|
|
|
extras.gearAngles = extras.gearDefs.map(() => 0);
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.gearDefs) {
|
|
|
ex.gearDefs = [
|
|
|
{ cx: 0.35, cy: 0.4, r: 0.16, teeth: 12, dir: 1 },
|
|
|
{ cx: 0.62, cy: 0.38, r: 0.13, teeth: 10, dir: -1 },
|
|
|
{ cx: 0.48, cy: 0.65, r: 0.10, teeth: 8, dir: 1 },
|
|
|
{ cx: 0.25, cy: 0.68, r: 0.08, teeth: 6, dir: -1 },
|
|
|
{ cx: 0.72, cy: 0.62, r: 0.09, teeth: 7, dir: 1 },
|
|
|
];
|
|
|
ex.gearAngles = ex.gearDefs.map(() => 0);
|
|
|
}
|
|
|
const minDim = Math.min(canvas.width, canvas.height);
|
|
|
|
|
|
// Draw each gear
|
|
|
for (let g = 0; g < ex.gearDefs.length; g++) {
|
|
|
const gd = ex.gearDefs[g];
|
|
|
const gcx = gd.cx * canvas.width;
|
|
|
const gcy = gd.cy * canvas.height;
|
|
|
const gr = gd.r * minDim;
|
|
|
const col = shiftAccent(accent, (g - 2) * 20);
|
|
|
|
|
|
// Rotate gear angle
|
|
|
ex.gearAngles[g] += gd.dir * (0.003 + (1 / gd.teeth) * 0.008);
|
|
|
const rot = ex.gearAngles[g];
|
|
|
|
|
|
// Gear teeth outline
|
|
|
const toothH = gr * 0.15;
|
|
|
const toothW = (Math.PI * 2) / gd.teeth * 0.35;
|
|
|
ctx.beginPath();
|
|
|
for (let t = 0; t < gd.teeth; t++) {
|
|
|
const a = rot + (t / gd.teeth) * Math.PI * 2;
|
|
|
// Outer tooth corners
|
|
|
const a1 = a - toothW, a2 = a + toothW;
|
|
|
const outerR = gr + toothH;
|
|
|
// Valley between teeth
|
|
|
const va = a + (1 / gd.teeth) * Math.PI;
|
|
|
|
|
|
if (t === 0) {
|
|
|
ctx.moveTo(gcx + Math.cos(a1) * outerR, gcy + Math.sin(a1) * outerR);
|
|
|
}
|
|
|
ctx.lineTo(gcx + Math.cos(a2) * outerR, gcy + Math.sin(a2) * outerR);
|
|
|
// Down to rim
|
|
|
const nextA = rot + ((t + 1) / gd.teeth) * Math.PI * 2 - toothW;
|
|
|
ctx.lineTo(gcx + Math.cos(a2) * gr, gcy + Math.sin(a2) * gr);
|
|
|
ctx.lineTo(gcx + Math.cos(nextA) * gr, gcy + Math.sin(nextA) * gr);
|
|
|
ctx.lineTo(gcx + Math.cos(nextA) * outerR, gcy + Math.sin(nextA) * outerR);
|
|
|
}
|
|
|
ctx.closePath();
|
|
|
ctx.strokeStyle = `rgba(${col}, 0.06)`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Inner ring
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(gcx, gcy, gr * 0.4, 0, Math.PI * 2);
|
|
|
ctx.strokeStyle = `rgba(${col}, 0.05)`;
|
|
|
ctx.lineWidth = 0.8;
|
|
|
ctx.stroke();
|
|
|
|
|
|
// Spokes
|
|
|
for (let s = 0; s < 4; s++) {
|
|
|
const sa = rot + s * Math.PI * 0.5;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(gcx + Math.cos(sa) * gr * 0.15, gcy + Math.sin(sa) * gr * 0.15);
|
|
|
ctx.lineTo(gcx + Math.cos(sa) * gr * 0.4, gcy + Math.sin(sa) * gr * 0.4);
|
|
|
ctx.strokeStyle = `rgba(${col}, 0.04)`;
|
|
|
ctx.lineWidth = 1;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
// Center axle
|
|
|
const ag = ctx.createRadialGradient(gcx, gcy, 0, gcx, gcy, gr * 0.12);
|
|
|
ag.addColorStop(0, `rgba(${col}, 0.12)`);
|
|
|
ag.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(gcx, gcy, gr * 0.12, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = ag;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Axle dot
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(gcx, gcy, 2, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, 0.25)`;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── HELP — fireflies / lanterns illuminating the dark ──
|
|
|
help: {
|
|
|
count: 30,
|
|
|
init(p, i) {
|
|
|
p.x = Math.random() * canvas.width;
|
|
|
p.y = Math.random() * canvas.height;
|
|
|
p.vx = (Math.random() - 0.5) * 0.3;
|
|
|
p.vy = (Math.random() - 0.5) * 0.3;
|
|
|
p.phase = Math.random() * Math.PI * 2;
|
|
|
p.hueShift = (Math.random() - 0.5) * 40;
|
|
|
// Fireflies blink — each has its own rhythm
|
|
|
p.blinkSpeed = 0.6 + Math.random() * 1.2;
|
|
|
p.blinkOffset = Math.random() * Math.PI * 2;
|
|
|
p.radius = 1.5 + Math.random() * 1.5;
|
|
|
// Lanterns are bigger, steadier, brighter
|
|
|
p.isLantern = i < 6;
|
|
|
if (p.isLantern) {
|
|
|
p.radius = 3 + Math.random() * 2;
|
|
|
p.blinkSpeed = 0.2 + Math.random() * 0.3; // slower pulse
|
|
|
p.vx *= 0.4;
|
|
|
p.vy *= 0.4;
|
|
|
}
|
|
|
// Wander target — fireflies change direction
|
|
|
p.wanderAngle = Math.random() * Math.PI * 2;
|
|
|
p.wanderTimer = 0;
|
|
|
},
|
|
|
update(p) {
|
|
|
// Wander — change direction periodically
|
|
|
p.wanderTimer++;
|
|
|
if (p.wanderTimer > 80 + Math.random() * 60) {
|
|
|
p.wanderTimer = 0;
|
|
|
p.wanderAngle += (Math.random() - 0.5) * 2;
|
|
|
}
|
|
|
const wander = p.isLantern ? 0.003 : 0.008;
|
|
|
p.vx += Math.cos(p.wanderAngle) * wander;
|
|
|
p.vy += Math.sin(p.wanderAngle) * wander;
|
|
|
// Dampen
|
|
|
p.vx *= 0.98;
|
|
|
p.vy *= 0.98;
|
|
|
p.x += p.vx;
|
|
|
p.y += p.vy;
|
|
|
// Soft bounds
|
|
|
if (p.x < 30) p.wanderAngle = 0;
|
|
|
if (p.x > canvas.width - 30) p.wanderAngle = Math.PI;
|
|
|
if (p.y < 30) p.wanderAngle = Math.PI * 0.5;
|
|
|
if (p.y > canvas.height - 30) p.wanderAngle = -Math.PI * 0.5;
|
|
|
p.x = Math.max(10, Math.min(canvas.width - 10, p.x));
|
|
|
p.y = Math.max(10, Math.min(canvas.height - 10, p.y));
|
|
|
},
|
|
|
draw(p, ctx, accent, time) {
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
|
|
|
if (p.isLantern) {
|
|
|
// Lantern — large steady warm glow
|
|
|
const pulse = 0.7 + 0.3 * Math.sin(time * p.blinkSpeed + p.blinkOffset);
|
|
|
|
|
|
// Big light pool
|
|
|
const poolR = p.radius * 12;
|
|
|
const pool = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, poolR);
|
|
|
pool.addColorStop(0, `rgba(${col}, ${0.06 * pulse})`);
|
|
|
pool.addColorStop(0.3, `rgba(${col}, ${0.03 * pulse})`);
|
|
|
pool.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, poolR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = pool;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Bright core glow
|
|
|
const coreR = p.radius * 4;
|
|
|
const core = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, coreR);
|
|
|
core.addColorStop(0, `rgba(${col}, ${0.3 * pulse})`);
|
|
|
core.addColorStop(0.4, `rgba(${col}, ${0.12 * pulse})`);
|
|
|
core.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, coreR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = core;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Center bright point
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * 0.6, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(255, 255, 255, ${0.15 * pulse})`;
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${0.35 * pulse})`;
|
|
|
ctx.fill();
|
|
|
} else {
|
|
|
// Firefly — blinks on and off
|
|
|
const raw = Math.sin(time * p.blinkSpeed + p.blinkOffset);
|
|
|
const blink = Math.pow(Math.max(0, raw), 3); // sharp on, quick off
|
|
|
|
|
|
if (blink > 0.01) {
|
|
|
// Light pool when lit
|
|
|
const poolR = p.radius * 8 * blink;
|
|
|
const pool = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, poolR);
|
|
|
pool.addColorStop(0, `rgba(${col}, ${0.12 * blink})`);
|
|
|
pool.addColorStop(0.5, `rgba(${col}, ${0.04 * blink})`);
|
|
|
pool.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, poolR, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = pool;
|
|
|
ctx.fill();
|
|
|
|
|
|
// Bright core
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * (0.5 + blink * 0.5), 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, ${0.2 + blink * 0.5})`;
|
|
|
ctx.fill();
|
|
|
|
|
|
// White center flash
|
|
|
if (blink > 0.5) {
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * 0.3, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(255,255,255, ${(blink - 0.5) * 0.5})`;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
} else {
|
|
|
// Barely visible when dark
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(p.x, p.y, p.radius * 0.5, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = `rgba(${col}, 0.04)`;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
initExtras() {
|
|
|
extras.trails = {};
|
|
|
},
|
|
|
drawGlobal(ctx, parts, accent, time, layerExtras) {
|
|
|
const ex = layerExtras || extras;
|
|
|
if (!ex.trails) ex.trails = {};
|
|
|
|
|
|
// Faint trails behind fireflies — short luminous paths
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
const p = parts[i];
|
|
|
if (p.isLantern) continue;
|
|
|
const key = i;
|
|
|
if (!ex.trails[key]) ex.trails[key] = [];
|
|
|
ex.trails[key].push({ x: p.x, y: p.y });
|
|
|
if (ex.trails[key].length > 10) ex.trails[key].shift();
|
|
|
|
|
|
const raw = Math.sin(time * p.blinkSpeed + p.blinkOffset);
|
|
|
const blink = Math.pow(Math.max(0, raw), 3);
|
|
|
if (blink < 0.1) continue;
|
|
|
|
|
|
const trail = ex.trails[key];
|
|
|
const col = shiftAccent(accent, p.hueShift);
|
|
|
for (let t = 0; t < trail.length - 1; t++) {
|
|
|
const frac = t / trail.length;
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(trail[t].x, trail[t].y);
|
|
|
ctx.lineTo(trail[t + 1].x, trail[t + 1].y);
|
|
|
ctx.strokeStyle = `rgba(${col}, ${frac * 0.06 * blink})`;
|
|
|
ctx.lineWidth = frac * 1.5;
|
|
|
ctx.lineCap = 'round';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
|
|
|
// ── NONE — fallback for pages without a preset ──
|
|
|
none: {
|
|
|
count: 0,
|
|
|
init() {},
|
|
|
update() {},
|
|
|
draw() {}
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// ── Transition System ──
|
|
|
// Phases: 'normal' | 'converge' | 'burst'
|
|
|
// converge: old particles move toward center, globalDraw fades out
|
|
|
// burst: new particles move from center to target positions
|
|
|
// normal: standard preset behavior
|
|
|
|
|
|
const TRANSITION_SPEED = 0.025; // 0→1 per frame, ~40 frames = ~0.67s
|
|
|
|
|
|
let currentLayer = null; // { preset, particles, extras, name }
|
|
|
let transitionState = null; // null or { phase, progress, oldParticles, newLayer, cx, cy }
|
|
|
|
|
|
function initLayer(presetName) {
|
|
|
const preset = PRESETS[presetName] || PRESETS.none;
|
|
|
const parts = [];
|
|
|
const layerExtras = {};
|
|
|
resize();
|
|
|
for (let i = 0; i < preset.count; i++) {
|
|
|
const p = {};
|
|
|
preset.init(p, i);
|
|
|
parts.push(p);
|
|
|
}
|
|
|
if (preset.initExtras) {
|
|
|
const saved = extras;
|
|
|
extras = layerExtras;
|
|
|
preset.initExtras();
|
|
|
extras = saved;
|
|
|
}
|
|
|
return { preset, particles: parts, extras: layerExtras, name: presetName };
|
|
|
}
|
|
|
|
|
|
function loop() {
|
|
|
animFrame = requestAnimationFrame(loop);
|
|
|
|
|
|
const w = canvas.width, h = canvas.height;
|
|
|
if (w === 0 || h === 0) { resize(); return; }
|
|
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
const accent = getAccentRGB();
|
|
|
const time = Date.now() * 0.001;
|
|
|
frameCount++;
|
|
|
|
|
|
const cx = w * 0.5, cy = h * 0.5;
|
|
|
|
|
|
if (transitionState) {
|
|
|
const ts = transitionState;
|
|
|
ts.progress = Math.min(1, ts.progress + TRANSITION_SPEED);
|
|
|
|
|
|
if (ts.phase === 'converge') {
|
|
|
// Draw old particles converging to center
|
|
|
const t = ts.progress;
|
|
|
const ease = t * t; // ease-in
|
|
|
const alpha = 1 - t * 0.5; // fade slightly
|
|
|
|
|
|
ctx.globalAlpha = alpha;
|
|
|
for (const p of ts.oldParticles) {
|
|
|
// Lerp from current position toward center
|
|
|
const dx = cx - p.ox;
|
|
|
const dy = cy - p.oy;
|
|
|
p.x = p.ox + dx * ease;
|
|
|
p.y = p.oy + dy * ease;
|
|
|
}
|
|
|
|
|
|
// Draw with old preset (no global effects during converge — faded)
|
|
|
const oldPreset = ts.oldPreset;
|
|
|
if (oldPreset) {
|
|
|
ctx.globalAlpha = alpha * (1 - ease * 0.8);
|
|
|
if (oldPreset.drawGlobal && ts.oldExtras) {
|
|
|
oldPreset.drawGlobal(ctx, ts.oldParticles, accent, time, ts.oldExtras);
|
|
|
}
|
|
|
ctx.globalAlpha = alpha;
|
|
|
for (const p of ts.oldParticles) {
|
|
|
oldPreset.draw(p, ctx, accent, time);
|
|
|
}
|
|
|
}
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
// Center glow builds as particles converge
|
|
|
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 30 + ease * 20);
|
|
|
cg.addColorStop(0, `rgba(${accent}, ${ease * 0.4})`);
|
|
|
cg.addColorStop(0.5, `rgba(${accent}, ${ease * 0.15})`);
|
|
|
cg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, 30 + ease * 20, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = cg;
|
|
|
ctx.fill();
|
|
|
|
|
|
if (ts.progress >= 1) {
|
|
|
// Switch to burst phase
|
|
|
ts.phase = 'burst';
|
|
|
ts.progress = 0;
|
|
|
// Store target positions for new particles
|
|
|
currentLayer = ts.newLayer;
|
|
|
for (const p of currentLayer.particles) {
|
|
|
p.tx = p.x;
|
|
|
p.ty = p.y;
|
|
|
p.x = cx;
|
|
|
p.y = cy;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
} else if (ts.phase === 'burst') {
|
|
|
const t = ts.progress;
|
|
|
const ease = 1 - Math.pow(1 - t, 3); // ease-out
|
|
|
|
|
|
// Center flash that fades
|
|
|
const flashAlpha = Math.max(0, (1 - t) * 0.5);
|
|
|
if (flashAlpha > 0.01) {
|
|
|
const fg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 50);
|
|
|
fg.addColorStop(0, `rgba(${accent}, ${flashAlpha})`);
|
|
|
fg.addColorStop(0.4, `rgba(${accent}, ${flashAlpha * 0.3})`);
|
|
|
fg.addColorStop(1, 'rgba(0,0,0,0)');
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(cx, cy, 50, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = fg;
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
// Lerp new particles from center to target
|
|
|
const layer = currentLayer;
|
|
|
for (const p of layer.particles) {
|
|
|
p.x = cx + (p.tx - cx) * ease;
|
|
|
p.y = cy + (p.ty - cy) * ease;
|
|
|
}
|
|
|
|
|
|
// Draw new preset (global effects fade in)
|
|
|
ctx.globalAlpha = ease;
|
|
|
if (layer.preset.drawGlobal) {
|
|
|
layer.preset.drawGlobal(ctx, layer.particles, accent, time, layer.extras);
|
|
|
}
|
|
|
ctx.globalAlpha = 0.5 + ease * 0.5; // particles visible earlier
|
|
|
for (const p of layer.particles) {
|
|
|
layer.preset.draw(p, ctx, accent, time);
|
|
|
}
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
if (ts.progress >= 1) {
|
|
|
// Restore true positions and enter normal
|
|
|
for (const p of layer.particles) {
|
|
|
p.x = p.tx;
|
|
|
p.y = p.ty;
|
|
|
delete p.tx;
|
|
|
delete p.ty;
|
|
|
}
|
|
|
transitionState = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
} else if (currentLayer && currentLayer.preset.count > 0) {
|
|
|
// Normal rendering
|
|
|
const layer = currentLayer;
|
|
|
for (let i = 0; i < layer.particles.length; i++) {
|
|
|
layer.preset.update(layer.particles[i], time, i);
|
|
|
}
|
|
|
if (layer.preset.drawGlobal) {
|
|
|
layer.preset.drawGlobal(ctx, layer.particles, accent, time, layer.extras);
|
|
|
}
|
|
|
for (let i = 0; i < layer.particles.length; i++) {
|
|
|
layer.preset.draw(layer.particles[i], ctx, accent, time);
|
|
|
}
|
|
|
} else {
|
|
|
// Nothing to draw — stop loop
|
|
|
cancelAnimationFrame(animFrame);
|
|
|
animFrame = null;
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Public API ──
|
|
|
|
|
|
function setPreset(name) {
|
|
|
// Already showing this preset? Skip.
|
|
|
if (currentLayer && currentLayer.name === name) return;
|
|
|
|
|
|
const newLayer = initLayer(name);
|
|
|
const isNone = (PRESETS[name] || PRESETS.none).count === 0;
|
|
|
|
|
|
if (currentLayer && currentLayer.preset.count > 0 && !isNone) {
|
|
|
// Transition: converge old → burst new
|
|
|
// Snapshot old particle positions
|
|
|
const oldParticles = currentLayer.particles.map(p => ({ ...p, ox: p.x, oy: p.y }));
|
|
|
transitionState = {
|
|
|
phase: 'converge',
|
|
|
progress: 0,
|
|
|
oldParticles,
|
|
|
oldPreset: currentLayer.preset,
|
|
|
oldExtras: currentLayer.extras,
|
|
|
newLayer
|
|
|
};
|
|
|
} else if (!isNone) {
|
|
|
// No old layer (first load or from 'none') — burst from center
|
|
|
currentLayer = newLayer;
|
|
|
const cx = canvas.width * 0.5, cy = canvas.height * 0.5;
|
|
|
for (const p of currentLayer.particles) {
|
|
|
p.tx = p.x;
|
|
|
p.ty = p.y;
|
|
|
p.x = cx;
|
|
|
p.y = cy;
|
|
|
}
|
|
|
transitionState = {
|
|
|
phase: 'burst',
|
|
|
progress: 0,
|
|
|
oldParticles: [],
|
|
|
oldPreset: null,
|
|
|
oldExtras: null,
|
|
|
newLayer: currentLayer
|
|
|
};
|
|
|
} else {
|
|
|
// Switching to 'none' — just fade out
|
|
|
if (currentLayer) {
|
|
|
// Quick converge to center then stop
|
|
|
const oldParticles = currentLayer.particles.map(p => ({ ...p, ox: p.x, oy: p.y }));
|
|
|
transitionState = {
|
|
|
phase: 'converge',
|
|
|
progress: 0,
|
|
|
oldParticles,
|
|
|
oldPreset: currentLayer.preset,
|
|
|
oldExtras: currentLayer.extras,
|
|
|
newLayer: initLayer('none')
|
|
|
};
|
|
|
}
|
|
|
currentLayer = null;
|
|
|
}
|
|
|
|
|
|
// Ensure loop is running
|
|
|
if (!animFrame) loop();
|
|
|
}
|
|
|
|
|
|
function stop() {
|
|
|
if (animFrame) {
|
|
|
cancelAnimationFrame(animFrame);
|
|
|
animFrame = null;
|
|
|
}
|
|
|
currentLayer = null;
|
|
|
transitionState = null;
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
}
|
|
|
|
|
|
// Page ID → preset name mapping
|
|
|
const PAGE_PRESETS = {
|
|
|
dashboard: 'dashboard',
|
|
|
sync: 'sync',
|
|
|
downloads: 'search',
|
|
|
discover: 'discover',
|
|
|
artists: 'artists',
|
|
|
automations: 'automations',
|
|
|
library: 'library',
|
|
|
import: 'import',
|
|
|
settings: 'settings',
|
|
|
help: 'help',
|
|
|
};
|
|
|
|
|
|
// Listen for page changes from script.js
|
|
|
window.pageParticles = {
|
|
|
setPage(pageId) {
|
|
|
const presetName = PAGE_PRESETS[pageId] || 'none';
|
|
|
setPreset(presetName);
|
|
|
},
|
|
|
stop
|
|
|
};
|
|
|
|
|
|
// Auto-start for initial page (respect particles toggle)
|
|
|
requestAnimationFrame(() => {
|
|
|
if (window._particlesEnabled === false) return;
|
|
|
const activePage = document.querySelector('.page.active');
|
|
|
if (activePage) {
|
|
|
const pageId = activePage.id.replace('-page', '');
|
|
|
window.pageParticles.setPage(pageId);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
})();
|