/** * particles.js — ApolloNvim Vesmírný Particle Systém * Canvas-based animace hvězd s parallaxem, blikáním, spojeními a padajícími hvězdami. */ class ParticleSystem { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas.getContext('2d'); // Konfigurace částic this.config = { count: 200, minSize: 0.5, maxSize: 2.5, colors: ['#ffffff', '#8be9fd', '#bd93f9', '#ff79c6'], connectionDist: 100, // px — maximální vzdálenost pro spojovací linky driftSpeed: 0.25, // max px/frame náhodný drift parallaxStrength: 0.04, // síla parallax efektu myši blinkChance: 0.35, // podíl hvězd, které blikají shootingInterval:[5000, 8000], // ms min/max mezi padajícími hvězdami }; // Stav myši this.mouse = { x: 0, y: 0, active: false }; // Pole částic a padající hvězdy this.particles = []; this.shootingStars = []; this._animFrameId = null; this._shootingTimer = null; this.init(); } // ─── Inicializace ───────────────────────────────────────────────────────── init() { this.resize(); this.createParticles(); this._bindEvents(); this._scheduleShootingStar(); this.animate(); } /** Nastaví rozměry canvasu dle okna */ resize() { this.canvas.width = window.innerWidth; this.canvas.height = window.innerHeight; } /** Vytvoří pole hvězdičkových částic */ createParticles() { this.particles = []; const { count, minSize, maxSize, colors, blinkChance, driftSpeed } = this.config; for (let i = 0; i < count; i++) { const blinks = Math.random() < blinkChance; this.particles.push({ x: Math.random() * this.canvas.width, y: Math.random() * this.canvas.height, baseX: 0, // bude nastaveno níže baseY: 0, size: minSize + Math.random() * (maxSize - minSize), color: colors[Math.floor(Math.random() * colors.length)], // drift – každá hvězda má svůj vektor vx: (Math.random() - 0.5) * driftSpeed, vy: (Math.random() - 0.5) * driftSpeed, // blikání blinks, opacity: blinks ? 0.3 + Math.random() * 0.7 : 0.6 + Math.random() * 0.4, blinkSpeed: blinks ? 0.005 + Math.random() * 0.015 : 0, blinkDir: Math.random() < 0.5 ? 1 : -1, // parallax offset (akumulovaný) offsetX: 0, offsetY: 0, }); // baseX/baseY = počáteční pozice (pro parallax výpočet) this.particles[i].baseX = this.particles[i].x; this.particles[i].baseY = this.particles[i].y; } } // ─── Hlavní animační smyčka ─────────────────────────────────────────────── animate() { this._animFrameId = requestAnimationFrame(() => this.animate()); const ctx = this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // Výpočet parallax posunu středu obrazovky const cx = this.canvas.width / 2; const cy = this.canvas.height / 2; const mouseOffsetX = this.mouse.active ? (this.mouse.x - cx) * this.config.parallaxStrength : 0; const mouseOffsetY = this.mouse.active ? (this.mouse.y - cy) * this.config.parallaxStrength : 0; // ── Aktualizace a kreslení hvězd ── for (const p of this.particles) { // Drift p.baseX += p.vx; p.baseY += p.vy; // Wrap-around přes okraje if (p.baseX < -10) p.baseX = this.canvas.width + 10; if (p.baseX > this.canvas.width + 10) p.baseX = -10; if (p.baseY < -10) p.baseY = this.canvas.height + 10; if (p.baseY > this.canvas.height + 10) p.baseY = -10; // Parallax offset (každá hvězda reaguje trochu jinak dle velikosti) const parallaxFactor = p.size / this.config.maxSize; p.x = p.baseX + mouseOffsetX * parallaxFactor; p.y = p.baseY + mouseOffsetY * parallaxFactor; // Blikání opacity if (p.blinks) { p.opacity += p.blinkSpeed * p.blinkDir; if (p.opacity >= 1.0) { p.opacity = 1.0; p.blinkDir = -1; } if (p.opacity <= 0.15) { p.opacity = 0.15; p.blinkDir = 1; } } // Kreslení hvězdy jako malého hvězdičkového tvaru this._drawStar(p); } // ── Spojovací linky ── this.drawConnections(); // ── Padající hvězdy ── this._updateShootingStars(); } /** Nakreslí hvězdu (5-cípý tvar pro větší, kroužek pro malé) */ _drawStar(p) { const ctx = this.ctx; ctx.save(); ctx.globalAlpha = p.opacity; ctx.fillStyle = p.color; if (p.size >= 1.5) { // Pěticípá hvězdička pro větší částice this._drawStarShape(ctx, p.x, p.y, p.size * 2, p.size, 5); } else { // Jednoduchý kroužek pro malé hvězdičky ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); } // Jemná záře kolem hvězdy if (p.size > 1.2) { const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 4); grd.addColorStop(0, this._hexAlpha(p.color, 0.3 * p.opacity)); grd.addColorStop(1, this._hexAlpha(p.color, 0)); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 4, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } /** Nakreslí pěticípou hvězdičku */ _drawStarShape(ctx, cx, cy, outerR, innerR, points) { const step = Math.PI / points; ctx.beginPath(); for (let i = 0; i < points * 2; i++) { const r = i % 2 === 0 ? outerR : innerR; const angle = i * step - Math.PI / 2; const x = cx + Math.cos(angle) * r; const y = cy + Math.sin(angle) * r; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); } // ─── Spojovací linky ───────────────────────────────────────────────────── drawConnections() { const ctx = this.ctx; const dist = this.config.connectionDist; const ps = this.particles; for (let i = 0; i < ps.length; i++) { for (let j = i + 1; j < ps.length; j++) { const dx = ps[i].x - ps[j].x; const dy = ps[i].y - ps[j].y; const d = Math.sqrt(dx * dx + dy * dy); if (d < dist) { const alpha = (1 - d / dist) * 0.25; // max 25% opacity ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = '#8be9fd'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(ps[i].x, ps[i].y); ctx.lineTo(ps[j].x, ps[j].y); ctx.stroke(); ctx.restore(); } } } } // ─── Padající hvězdy ───────────────────────────────────────────────────── /** Naplánuje další padající hvězdu s náhodným zpožděním */ _scheduleShootingStar() { const [min, max] = this.config.shootingInterval; const delay = min + Math.random() * (max - min); this._shootingTimer = setTimeout(() => { this.createShootingStar(); this._scheduleShootingStar(); // naplánuje další }, delay); } /** Vytvoří novou padající hvězdu */ createShootingStar() { const angle = (15 + Math.random() * 25) * (Math.PI / 180); // 15°–40° const speed = 6 + Math.random() * 8; const length = 80 + Math.random() * 120; this.shootingStars.push({ x: Math.random() * this.canvas.width * 0.8, y: Math.random() * this.canvas.height * 0.3, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, length, opacity: 1, trail: [], // pole minulých pozic pro ocas maxTrail: Math.floor(length / speed), done: false, }); } /** Aktualizuje a kreslí všechny padající hvězdy */ _updateShootingStars() { const ctx = this.ctx; this.shootingStars = this.shootingStars.filter(s => !s.done); for (const s of this.shootingStars) { // Uloží pozici do ocasu s.trail.push({ x: s.x, y: s.y }); if (s.trail.length > s.maxTrail) s.trail.shift(); // Pohyb s.x += s.vx; s.y += s.vy; s.opacity -= 0.012; if (s.opacity <= 0 || s.x > this.canvas.width + 50 || s.y > this.canvas.height + 50) { s.done = true; continue; } // Kreslení ocasu (gradient linka) if (s.trail.length > 1) { const grad = ctx.createLinearGradient( s.trail[0].x, s.trail[0].y, s.x, s.y ); grad.addColorStop(0, `rgba(255,255,255,0)`); grad.addColorStop(1, `rgba(255,255,255,${s.opacity})`); ctx.save(); ctx.strokeStyle = grad; ctx.lineWidth = 1.5; ctx.shadowColor = '#ffffff'; ctx.shadowBlur = 6; ctx.beginPath(); ctx.moveTo(s.trail[0].x, s.trail[0].y); for (const pt of s.trail) ctx.lineTo(pt.x, pt.y); ctx.lineTo(s.x, s.y); ctx.stroke(); ctx.restore(); } // Jasná tečka na špičce ctx.save(); ctx.globalAlpha = s.opacity; ctx.fillStyle = '#ffffff'; ctx.shadowColor = '#8be9fd'; ctx.shadowBlur = 10; ctx.beginPath(); ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } // ─── Pomocné metody ─────────────────────────────────────────────────────── /** Převede HEX barvu na rgba string s danou průhledností */ _hexAlpha(hex, alpha) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${alpha})`; } /** Naváže posluchače událostí */ _bindEvents() { // Resize window.addEventListener('resize', () => { this.resize(); // Po resize přepočítá baseX/baseY aby hvězdy zůstaly proporcionálně const scaleX = this.canvas.width / (this.canvas.width || 1); const scaleY = this.canvas.height / (this.canvas.height || 1); for (const p of this.particles) { p.baseX = Math.random() * this.canvas.width; p.baseY = Math.random() * this.canvas.height; } }); // Mouse parallax window.addEventListener('mousemove', (e) => { this.mouse.x = e.clientX; this.mouse.y = e.clientY; this.mouse.active = true; }); // Dotyk (mobile) window.addEventListener('touchmove', (e) => { if (e.touches.length > 0) { this.mouse.x = e.touches[0].clientX; this.mouse.y = e.touches[0].clientY; this.mouse.active = true; } }, { passive: true }); window.addEventListener('mouseleave', () => { this.mouse.active = false; }); } /** Zastaví animaci a časovače */ destroy() { if (this._animFrameId) cancelAnimationFrame(this._animFrameId); if (this._shootingTimer) clearTimeout(this._shootingTimer); } } // ─── Auto-init při načtení DOM ───────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('particles-canvas')) { window.particleSystem = new ParticleSystem('particles-canvas'); } });