+ + +
@@ -5090,6 +5093,7 @@ + \ No newline at end of file diff --git a/webui/static/particles.js b/webui/static/particles.js new file mode 100644 index 00000000..a82ed2c7 --- /dev/null +++ b/webui/static/particles.js @@ -0,0 +1,2295 @@ +// ============================================================ +// SoulSync — Page Particle System +// Single canvas, per-page particle behaviors +// ============================================================ + +(function () { + 'use strict'; + + 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 ── + + function getAccentRGB() { + const s = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim(); + return s || '29, 185, 84'; + } + + // 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.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.6; + + // Update emitters — spawn rings + for (const em of ex.emitters) { + em.timer++; + if (em.timer >= em.interval) { + 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 * 2.5; + } + } + } + } + + // 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 + requestAnimationFrame(() => { + const activePage = document.querySelector('.page.active'); + if (activePage) { + const pageId = activePage.id.replace('-page', ''); + window.pageParticles.setPage(pageId); + } + }); + +})(); diff --git a/webui/static/script.js b/webui/static/script.js index eea92f28..fece11f5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1497,6 +1497,9 @@ function navigateToPage(pageId) { // Load page-specific data loadPageData(pageId); + + // Update page background particles + if (window.pageParticles) window.pageParticles.setPage(pageId); } // REPLACE your old loadPageData function with this one: diff --git a/webui/static/style.css b/webui/static/style.css index 8465d48d..b5a02020 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -1424,9 +1424,25 @@ body { rgba(15, 15, 15, 1) 50%, rgba(11, 11, 11, 1) 100%); overflow: auto; + position: relative; } +/* Global page particles canvas — sits behind all page content */ +#page-particles-canvas { + position: fixed; + top: 0; + left: 240px; /* sidebar width */ + right: 0; + bottom: 0; + width: calc(100vw - 240px); + height: 100vh; + pointer-events: none; + z-index: 0; + opacity: 0.55; +} .page { + position: relative; + z-index: 1; display: none; height: 100%; padding: 40px; @@ -2628,8 +2644,8 @@ body { grid-template-columns: 1fr 370px; /* Left panel is flexible, right is fixed */ gap: 24px; - height: calc(100vh - 80px); - /* Fill page height, accounting for padding */ + height: 100%; + /* Fill parent .page content area */ padding: 0 40px 40px 40px; } @@ -2638,8 +2654,7 @@ body { display: flex; flex-direction: column; gap: 16px; - overflow: hidden; - /* Prevent content from overflowing panels */ + min-height: 0; } .downloads-side-panel { @@ -2800,7 +2815,7 @@ body { /* Search Bar: Replicates create_elegant_search_bar() */ .search-bar-container { - background: linear-gradient(to bottom, rgba(50, 50, 50, 0.8), rgba(40, 40, 40, 0.9)); + background: linear-gradient(to bottom, rgba(50, 50, 50, 0.55), rgba(40, 40, 40, 0.65)); border-radius: 12px; border: 1px solid rgba(var(--accent-rgb), 0.3); padding: 16px 20px; @@ -2878,15 +2893,15 @@ body { /* Search Results Area */ .search-results-container { - background: rgba(20, 20, 20, 0.3); + background: rgba(20, 20, 20, 0.2); border-radius: 12px; border: 1px solid rgba(64, 64, 64, 0.2); padding: 16px; display: flex; flex-direction: column; flex-grow: 1; + min-height: 0; /* Take remaining vertical space */ - overflow: hidden; } .search-results-header h3 { @@ -3200,7 +3215,7 @@ body { /* Right Panel: Controls & Download Queue */ .controls-panel { - background: linear-gradient(to bottom, rgba(40, 40, 40, 0.85), rgba(25, 25, 25, 0.95)); + background: linear-gradient(to bottom, rgba(40, 40, 40, 0.6), rgba(25, 25, 25, 0.7)); border-radius: 18px; border: 1px solid rgba(80, 80, 80, 0.4); padding: 14px 8px; @@ -5041,10 +5056,10 @@ body { /* Spacing between sections */ padding: 28px 24px 30px 24px; - /* Apple-style liquid glassmorphic foundation */ + /* Semi-transparent to let page particles show through */ background: linear-gradient(135deg, - rgba(20, 20, 20, 0.85) 0%, - rgba(12, 12, 12, 0.92) 100%); + rgba(20, 20, 20, 0.55) 0%, + rgba(12, 12, 12, 0.62) 100%); border-radius: 24px; border: 1px solid rgba(255, 255, 255, 0.08); border-top: 1px solid rgba(255, 255, 255, 0.12); @@ -6305,7 +6320,7 @@ body { .sync-main-panel, .sync-sidebar { - background: linear-gradient(135deg, rgba(26, 26, 26, 0.95), rgba(18, 18, 18, 0.98)); + background: linear-gradient(135deg, rgba(26, 26, 26, 0.65), rgba(18, 18, 18, 0.72)); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08); padding: 20px; @@ -24432,7 +24447,7 @@ body { display: flex; flex-direction: column; flex-grow: 1; - overflow: hidden; + min-height: 0; } /* ========================================= */ @@ -24701,7 +24716,7 @@ body { border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); max-height: min(70vh, 800px); - overflow: hidden; + overflow-y: auto; z-index: 1000; backdrop-filter: blur(40px); animation: slideDown 0.15s ease-out; @@ -24958,7 +24973,6 @@ body { 0 8px 24px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05); position: relative; - overflow: hidden; } .enh-dropdown-section::before { @@ -32027,7 +32041,7 @@ textarea.enhanced-meta-field-input { .enhanced-tracks-panel-inner::-webkit-scrollbar-thumb:hover { background: rgba(var(--accent-rgb), 0.5); } /* Loading state for enhanced view */ -.enhanced-loading { +.enhanced-view-container .enhanced-loading { display: flex; align-items: center; justify-content: center; @@ -32036,7 +32050,7 @@ textarea.enhanced-meta-field-input { color: rgba(255,255,255,0.5); font-size: 14px; } -.enhanced-loading::before { +.enhanced-view-container .enhanced-loading::before { content: ''; width: 20px; height: 20px;