diff --git a/404.html b/404.html index 47fc01b..3dfaaff 100644 --- a/404.html +++ b/404.html @@ -15,13 +15,13 @@ 404 | ivanch - - + + - - + + @@ -45,7 +45,7 @@ - + diff --git a/README.md b/README.md index 613120c..2027f4a 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,56 @@ -

- Hey there, I'm José 👋 -

+```text + _ _ + (_)__ ____ _ _ __ ___| |__ + | |\ \ / / _` | '_ \ / __| '_ \ + | | \ V / (_| | | | | (__| | | | + |_| \_/ \__,_|_| |_|\___|_| |_| -

.NET Core | DevOps | Self-hosted Enthusiast

+ software, servers, and a little sorcery +``` -I design and automate resilient systems ensuring rapid delivery and rock‑solid reliability. -Passionate about building robust code, streamlining CI/CD, orchestrating Kubernetes, and contributing to open-source. - ---- - -

- .NET Core - React - Python - HTML5 - CSS3 -
- PostgreSQL - MySQL - Redis -
- Linux - Bash - Docker - Kubernetes - Terraform - GitHub Actions +

+ Website + GitHub + Gitea + Blog + LinkedIn

--- -

🚀 Featured Projects

+## `$ whoami` -- 🔍 **[OpenCand](https://github.com/ivanch/opencand)** - [opencand.ivanch.me](https://opencand.ivanch.me) - Simple Brazilian candidate data viewer and searcher, made with .NET Core and React +Hey, I'm **José**, usually found online as **ivanch**. -- 📃 **[Vision Start](https://github.com/ivanch/vision-start)** - A glassmorphism-looking like, modern and customizable Chrome startpage +I build software with .NET, automate the path from commit to deployment, +and give my spare hardware more responsibilities than it probably asked for. +My corner of the internet is part portfolio, part doorway into the homelab. -- ⚡️ **[Haven](https://github.com/ivanch/haven)** - *personal homelab* - Bare-metal Kubernetes cluster using k3s, some other Docker containers, and some sorcery +```text +interests +|-- building robust and maintainable software +|-- making CI/CD do the repetitive work +|-- orchestrating containers with Kubernetes +`-- self-hosting, tinkering, and open source +``` -- 🎶 **[Kasbot](https://github.com/ivanch/kasbot)** - Discord bot inspired by a Brazilian DJ +## `$ ls toolbox/` - +A few things I've sent out into the world: + +| Project | What it does | +| :--- | :--- | +| **[OpenCand](https://github.com/ivanch/opencand)** | Search and explore Brazilian candidate data with .NET Core and React | +| **[Vision Start](https://github.com/ivanch/vision-start)** | A customizable Chrome startpage with a glassmorphism look | +| **[Kasbot](https://github.com/ivanch/kasbot)** | A Discord bot inspired by a Brazilian DJ | +| **[Candlebright](https://github.com/ivanch/candlebright)** | A game project written in C++ | +| **[Haven](https://github.com/ivanch/haven)** | My personal homelab (now with GitOps!) | diff --git a/assets/fonts/remixicon-subset.woff2 b/assets/fonts/remixicon-subset.woff2 new file mode 100644 index 0000000..8a9bf59 Binary files /dev/null and b/assets/fonts/remixicon-subset.woff2 differ diff --git a/assets/lain.jpg b/assets/lain.jpg new file mode 100644 index 0000000..e347a84 Binary files /dev/null and b/assets/lain.jpg differ diff --git a/assets/lain.png b/assets/lain.png deleted file mode 100644 index 489087e..0000000 Binary files a/assets/lain.png and /dev/null differ diff --git a/css/icons.css b/css/icons.css new file mode 100644 index 0000000..e4fd882 --- /dev/null +++ b/css/icons.css @@ -0,0 +1,35 @@ +/* +* Remix Icon v3.5.0 (subset) +* https://remixicon.com +* Released under the Apache License Version 2.0 +* +* Only the icons used by the site are included. The font file is a subset of +* remixicon.woff2 that contains just these glyphs. To add an icon, copy its +* rule from the full remixicon.css and re-subset the font with its codepoint. +*/ +@font-face { + font-family: "remixicon"; + src: url("../assets/fonts/remixicon-subset.woff2") format("woff2"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +[class^="ri-"], [class*=" ri-"] { + font-family: 'remixicon' !important; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ri-article-line:before { content: "\ea7e"; } +.ri-briefcase-line:before { content: "\eaf5"; } +.ri-building-2-fill:before { content: "\eb08"; } +.ri-git-repository-fill:before { content: "\edc6"; } +.ri-github-fill:before { content: "\edca"; } +.ri-global-line:before { content: "\edcf"; } +.ri-graduation-cap-fill:before { content: "\f332"; } +.ri-home-5-line:before { content: "\ee1f"; } +.ri-linkedin-fill:before { content: "\eeb5"; } +.ri-mail-fill:before { content: "\eef3"; } +.ri-terminal-box-line:before { content: "\f1f6"; } diff --git a/css/main.css b/css/main.css index 50e8979..c61b0e5 100644 --- a/css/main.css +++ b/css/main.css @@ -59,7 +59,6 @@ img { border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border); box-shadow: var(--shadow-lg), var(--inset-shadow); - will-change: box-shadow; animation: glass-shadow 10s ease-in-out infinite; } diff --git a/css/matrix.css b/css/matrix.css index bff6f27..737f03f 100644 --- a/css/matrix.css +++ b/css/matrix.css @@ -8,10 +8,12 @@ pointer-events: none; overflow: hidden; background: rgba(0, 0, 0, 0.88); + /* Colors are pre-dimmed (x0.7) rather than using `filter: brightness()`, + which would re-filter the whole animated canvas every frame. The + particle colors get the same treatment in js/flowfield.js. */ background-image: - radial-gradient(120% 100% at 50% 0%, rgba(20, 24, 48, 0.55) 0%, rgba(0, 0, 0, 0) 60%), - linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(18, 18, 38, 0.72) 100%); - filter: brightness(0.7) saturate(1.05); + radial-gradient(120% 100% at 50% 0%, rgba(14, 17, 34, 0.55) 0%, rgba(0, 0, 0, 0) 60%), + linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(13, 13, 27, 0.72) 100%); } /* CSS only on phones/tablets and for reduced motion: no canvas work. */ @@ -37,10 +39,6 @@ animation: none; } - .main-container .glass { - will-change: auto; - } - /* Avoid blur compositing work while scrolling. */ body .main-container :is(.glass, .content, .projects-section, .homelab-section, .btn) { backdrop-filter: none; diff --git a/index.html b/index.html index 320a66b..8de865e 100644 --- a/index.html +++ b/index.html @@ -14,18 +14,18 @@ ivanch - + - + - - + + @@ -34,7 +34,7 @@
- Avatar + Avatar
@@ -149,7 +149,7 @@
- + diff --git a/js/flowfield.js b/js/flowfield.js index 44f4f3d..555ae9c 100644 --- a/js/flowfield.js +++ b/js/flowfield.js @@ -10,6 +10,12 @@ * Trails are drawn by erasing the previous frame with a low-alpha * `destination-out` pass, so the CSS gradient painted behind the canvas * element keeps showing through instead of being flooded by an opaque fill. + * + * Performance notes: particles are bucketed by (tone, width, alpha) and each + * bucket is stroked as a single path, so a frame issues roughly a third of + * the draw calls of one-per-particle, with no per-frame string allocation. + * The loop is capped at ~60 fps so high-refresh displays don't do 2x the + * work (the simulation is frame-based, so this also keeps speed consistent). */ /* Deterministic PRNG so the noise permutation is stable within a page load. */ @@ -117,6 +123,8 @@ class FlowFieldBackground { this.mouse = { x: 0, y: 0, active: false }; this.rafId = null; this.resizeRaf = null; + this.lastFrame = 0; + this.frameInterval = 1000 / 60; this.events = new AbortController(); this.width = 0; @@ -151,9 +159,42 @@ class FlowFieldBackground { { weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet ]; + this.buildStyleCache(); this.init(); } + buildStyleCache() { + // Colors are dimmed here instead of with a CSS `filter: brightness()` + // on the canvas, which would cost a full-screen filter pass per frame. + // Additive blending is linear, so the result is nearly identical. + const dim = 0.7; + const dimRgb = (rgb) => rgb.split(',').map((c) => Math.round(c * dim)).join(', '); + for (const tone of this.palette) tone.drawRgb = dimRgb(tone.rgb); + const dotRgb = dimRgb('240, 248, 255'); + + // Alpha and line width are quantized so particles can share a path. + // Steps are small enough that the difference is not visible. + this.alphaStep = 0.02; + this.alphaLevels = Math.round(1 / this.alphaStep) + 1; + this.widthStep = 0.2; + this.widthLevels = 10; // covers the 0.4px - 1.8px particle range + + this.strokeStyles = []; + for (const tone of this.palette) { + for (let a = 0; a < this.alphaLevels; a++) { + this.strokeStyles.push(`rgba(${tone.drawRgb}, ${(a * this.alphaStep).toFixed(3)})`); + } + } + this.dotStyles = []; + for (let a = 0; a < this.alphaLevels; a++) { + this.dotStyles.push(`rgba(${dotRgb}, ${(a * this.alphaStep).toFixed(3)})`); + } + + const lineBuckets = this.palette.length * this.widthLevels * this.alphaLevels; + this.lineCounts = new Uint16Array(lineBuckets + 1); + this.dotCounts = new Uint16Array(this.alphaLevels + 1); + } + init() { this.bindEvents(); this.handleResize(); @@ -231,7 +272,9 @@ class FlowFieldBackground { this.width = w; this.height = h; - this.dpr = Math.min(window.devicePixelRatio || 1, 2); + // The strokes are faint and soft, so a 1x buffer looks nearly the same + // on HiDPI screens while using a quarter of the memory and fill cost. + this.dpr = 1; this.canvas.width = Math.round(w * this.dpr); this.canvas.height = Math.round(h * this.dpr); this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); @@ -243,21 +286,28 @@ class FlowFieldBackground { this.particles.push(this.createParticle()); } + // Scratch buffers for bucketing particles at draw time. + this.lineKeys = new Int16Array(count); + this.lineOrder = new Uint16Array(count); + this.dotKeys = new Int16Array(count); + this.dotOrder = new Uint16Array(count); + // Repaint a short exposure so a resize never flashes an empty canvas. this.warmup(30); } pickTone() { let roll = Math.random(); - for (const tone of this.palette) { - roll -= tone.weight; - if (roll <= 0) return tone; + for (let i = 0; i < this.palette.length; i++) { + roll -= this.palette[i].weight; + if (roll <= 0) return i; } - return this.palette[0]; + return 0; } createParticle() { - const tone = this.pickTone(); + const toneIndex = this.pickTone(); + const tone = this.palette[toneIndex]; const { min, max } = this.config.life; // 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight) @@ -289,7 +339,7 @@ class FlowFieldBackground { vy: 0, speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5), width: (0.7 + Math.random() * 0.5) * widthMult, - rgb: tone.rgb, + tone: toneIndex, baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult, alpha: 0, glow: 0, @@ -314,7 +364,7 @@ class FlowFieldBackground { for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 1.0 + Math.random() * 2.2; - const tone = this.pickTone(); + const tone = this.palette[this.pickTone()]; this.sparks.push({ x, y, @@ -322,7 +372,7 @@ class FlowFieldBackground { py: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, - rgb: tone.rgb, + rgb: tone.drawRgb, size: 0.6 + Math.random() * 0.8, alpha: 0.6 + Math.random() * 0.25, life: 0, @@ -441,34 +491,99 @@ class FlowFieldBackground { this.ctx.fillRect(0, 0, this.width, this.height); } + /* Counting sort: groups particle indices in `order` by their bucket key + (keys < 0 are skipped). Afterwards counts[k] is the end offset of + bucket k, and bucket k starts where bucket k - 1 ends. */ + sortByKey(keys, n, counts, order) { + counts.fill(0); + for (let i = 0; i < n; i++) { + if (keys[i] >= 0) counts[keys[i] + 1]++; + } + for (let k = 1; k < counts.length; k++) counts[k] += counts[k - 1]; + for (let i = 0; i < n; i++) { + const k = keys[i]; + if (k >= 0) order[counts[k]++] = i; + } + } + drawParticles() { const ctx = this.ctx; - // Additive blending gives a soft glow where currents overlap. - ctx.globalCompositeOperation = 'lighter'; - ctx.lineCap = 'round'; + const particles = this.particles; + const n = particles.length; + const { alphaStep, alphaLevels, widthStep, widthLevels, lineKeys, dotKeys } = this; + const glowBoost = this.config.glowBoost; - for (const p of this.particles) { + for (let i = 0; i < n; i++) { + const p = particles[i]; + lineKeys[i] = -1; + dotKeys[i] = -1; if (p.fresh) { p.fresh = false; continue; } - const alpha = Math.min(1, p.alpha + p.glow * this.config.glowBoost); - if (alpha <= 0.005) continue; + const alpha = Math.min(1, p.alpha + p.glow * glowBoost); + const a = Math.round(alpha / alphaStep); + if (a === 0) continue; - ctx.strokeStyle = `rgba(${p.rgb}, ${alpha.toFixed(3)})`; - ctx.lineWidth = p.width; - ctx.beginPath(); - ctx.moveTo(p.px, p.py); - ctx.lineTo(p.x, p.y); - ctx.stroke(); + const w = Math.min(widthLevels - 1, Math.round(p.width / widthStep)); + lineKeys[i] = (p.tone * widthLevels + w) * alphaLevels + a; - // Draw a subtle bright dot at the head of foreground particles + // A subtle bright dot at the head of foreground particles if (p.depth === 2 && (p.glow > 0.1 || Math.random() < 0.12)) { - ctx.fillStyle = `rgba(240, 248, 255, ${(alpha * 0.75).toFixed(3)})`; + dotKeys[i] = Math.round((alpha * 0.75) / alphaStep); + } + } + + // Additive blending gives a soft glow where currents overlap. It is + // also order-independent, so batching does not change the result. + ctx.globalCompositeOperation = 'lighter'; + ctx.lineCap = 'round'; + + const lineCounts = this.lineCounts; + const lineOrder = this.lineOrder; + this.sortByKey(lineKeys, n, lineCounts, lineOrder); + let start = 0; + for (let k = 0; k < lineCounts.length - 1; k++) { + const end = lineCounts[k]; + if (end === start) continue; + + const a = k % alphaLevels; + const toneWidth = (k - a) / alphaLevels; + const w = toneWidth % widthLevels; + const tone = (toneWidth - w) / widthLevels; + + ctx.strokeStyle = this.strokeStyles[tone * alphaLevels + a]; + ctx.lineWidth = w * widthStep; + ctx.beginPath(); + for (let j = start; j < end; j++) { + const p = particles[lineOrder[j]]; + ctx.moveTo(p.px, p.py); + ctx.lineTo(p.x, p.y); + } + ctx.stroke(); + start = end; + } + + const dotCounts = this.dotCounts; + const dotOrder = this.dotOrder; + this.sortByKey(dotKeys, n, dotCounts, dotOrder); + start = 0; + for (let a = 0; a < dotCounts.length - 1; a++) { + const end = dotCounts[a]; + if (end === start) continue; + + if (a > 0) { + ctx.fillStyle = this.dotStyles[a]; ctx.beginPath(); - ctx.arc(p.x, p.y, p.width * 0.75, 0, Math.PI * 2); + for (let j = start; j < end; j++) { + const p = particles[dotOrder[j]]; + const r = p.width * 0.75; + ctx.moveTo(p.x + r, p.y); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + } ctx.fill(); } + start = end; } } @@ -506,9 +621,15 @@ class FlowFieldBackground { } } - animate() { + animate(now = performance.now()) { + this.rafId = requestAnimationFrame((t) => this.animate(t)); + + // Cap at ~60 fps; keep the remainder so 120/144 Hz average out to 60. + const elapsed = now - this.lastFrame; + if (elapsed < this.frameInterval * 0.9) return; + this.lastFrame = now - (elapsed % this.frameInterval); + this.step(true); - this.rafId = requestAnimationFrame(() => this.animate()); } } diff --git a/js/matrix.js b/js/matrix.js deleted file mode 100644 index c70f0ad..0000000 --- a/js/matrix.js +++ /dev/null @@ -1,320 +0,0 @@ -class MatrixBackground { - constructor() { - this.canvas = document.getElementById('matrixCanvas'); - this.ctx = this.canvas.getContext('2d'); - this.dots = []; - this.connections = []; - this.mouse = { x: -9999, y: -9999, active: false }; - this.rafId = null; - this.isRunning = true; - this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - this.config = { - dotCount: this.getMobileDotCount(), - dotSpeed: 0.2, - connectionDuration: { min: 2000, max: 6000 }, - connectionChance: 0.0002, - maxConnections: this.getMobileMaxConnections(), - dotSize: 3, - mouseRadius: 110, - mouseForce: 0.6, - // Palette synced to the UI accent tokens - colors: { - dotDefault: 'rgba(230, 240, 255, 0.78)', - dotDefaultGlow: 'rgba(180, 220, 255, 0.40)', - dotConnected: 'rgba(120, 190, 245, 0.85)', - dotConnectedGlow: 'rgba(79, 195, 247, 0.55)', - connection: { - start: 'rgba(102, 126, 234, 0.22)', - middle: 'rgba(118, 75, 162, 0.62)', - end: 'rgba(79, 195, 247, 0.22)' - } - } - }; - - this.init(); - } - - getMobileDotCount() { - if (window.innerWidth <= 480) return 35; - if (window.innerWidth <= 768) return 45; - if (window.innerWidth >= 2100) return 100; - return 50; - } - - getMobileMaxConnections() { - if (window.innerWidth <= 480) return 4; - if (window.innerWidth <= 768) return 6; - return 8; - } - - init() { - this.handleResize(); - this.bindEvents(); - this.animate(); - } - - bindEvents() { - // Pointer reactivity — push dots away from cursor for a "matrix repel" feel. - // Touch devices are included; the dot pushes happen only while touched. - window.addEventListener('pointermove', (e) => { - this.mouse.x = e.clientX; - this.mouse.y = e.clientY; - this.mouse.active = true; - }, { passive: true }); - - window.addEventListener('pointerout', () => { - this.mouse.active = false; - this.mouse.x = -9999; - this.mouse.y = -9999; - }); - - // Pause the rAF loop when the tab is hidden to save CPU / battery. - document.addEventListener('visibilitychange', () => { - if (document.hidden) { - this.stop(); - } else { - this.start(); - } - }); - - // Resize is debounced via rAF; disabled on mobile for perf parity with original. - if (window.innerWidth > 768) { - let resizeRaf = null; - window.addEventListener('resize', () => { - if (resizeRaf) cancelAnimationFrame(resizeRaf); - resizeRaf = requestAnimationFrame(() => this.handleResize()); - }); - } - } - - start() { - if (this.rafId || !this.isRunning) return; - this.animate(); - } - - stop() { - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } - } - - handleResize() { - this.canvas.width = window.innerWidth; - this.canvas.height = window.innerHeight; - this.config.dotCount = this.getMobileDotCount(); - this.config.maxConnections = this.getMobileMaxConnections(); - this.createDots(); - } - - createDots() { - this.dots = []; - for (let i = 0; i < this.config.dotCount; i++) { - this.createDot(); - } - } - - createDot() { - const startX = Math.random() * (this.canvas.width - 20) + 10; - const startY = Math.random() * (this.canvas.height - 20) + 10; - - const angle = Math.random() * Math.PI * 2; - const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4); - const baseVelocityX = Math.cos(angle) * speed; - const baseVelocityY = Math.sin(angle) * speed; - - this.dots.push({ - x: startX, - y: startY, - vx: baseVelocityX, - vy: baseVelocityY, - baseVx: baseVelocityX, - baseVy: baseVelocityY, - wanderPhase: Math.random() * Math.PI * 2, - wanderSpeed: 0.01 + Math.random() * 0.01, - opacity: Math.random() * 0.3 + 0.7, - size: this.config.dotSize + Math.random() * 2, - connectionCount: 0 - }); - } - - updateDots() { - const radius = this.config.mouseRadius; - const radiusSq = radius * radius; - const force = this.config.mouseForce; - - this.dots.forEach(dot => { - // Mouse repulsion - if (this.mouse.active) { - const dx = dot.x - this.mouse.x; - const dy = dot.y - this.mouse.y; - const distSq = dx * dx + dy * dy; - if (distSq < radiusSq && distSq > 0.5) { - const dist = Math.sqrt(distSq); - const strength = (1 - dist / radius) * force; - dot.vx += (dx / dist) * strength; - dot.vy += (dy / dist) * strength; - } - } - - dot.x += dot.vx; - dot.y += dot.vy; - - // Ease back to a gentle autonomous drift after pointer interaction. - dot.wanderPhase += dot.wanderSpeed; - const wanderX = Math.cos(dot.wanderPhase) * 0.03; - const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03; - dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02; - dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02; - - // Reflect at the canvas edge. Keep the base velocity in sync with - // the bounce so the drift easing does not push the dot back into - // the same wall on the next frames. - const dotRadius = dot.size / 2; - const minX = dotRadius; - const minY = dotRadius; - const maxX = Math.max(minX, this.canvas.width - dotRadius); - const maxY = Math.max(minY, this.canvas.height - dotRadius); - - if (dot.x <= minX && dot.vx < 0) { - dot.x = minX + (minX - dot.x); - dot.vx = Math.abs(dot.vx); - dot.baseVx = Math.abs(dot.baseVx); - } else if (dot.x >= maxX && dot.vx > 0) { - dot.x = maxX - (dot.x - maxX); - dot.vx = -Math.abs(dot.vx); - dot.baseVx = -Math.abs(dot.baseVx); - } - - if (dot.y <= minY && dot.vy < 0) { - dot.y = minY + (minY - dot.y); - dot.vy = Math.abs(dot.vy); - dot.baseVy = Math.abs(dot.baseVy); - } else if (dot.y >= maxY && dot.vy > 0) { - dot.y = maxY - (dot.y - maxY); - dot.vy = -Math.abs(dot.vy); - dot.baseVy = -Math.abs(dot.baseVy); - } - - // A large pointer impulse can overshoot both edges by more than - // one frame, so clamp after reflecting the position. - dot.x = Math.max(minX, Math.min(maxX, dot.x)); - dot.y = Math.max(minY, Math.min(maxY, dot.y)); - }); - } - - drawDots() { - this.dots.forEach(dot => { - const isConnected = dot.connectionCount > 0; - this.ctx.beginPath(); - this.ctx.arc(dot.x, dot.y, dot.size / 2, 0, Math.PI * 2); - this.ctx.fillStyle = isConnected ? this.config.colors.dotConnected : this.config.colors.dotDefault; - this.ctx.globalAlpha = dot.opacity; - this.ctx.shadowBlur = 6; - this.ctx.shadowColor = isConnected ? this.config.colors.dotConnectedGlow : this.config.colors.dotDefaultGlow; - this.ctx.fill(); - }); - this.ctx.globalAlpha = 1; - this.ctx.shadowBlur = 0; - } - - createConnection(dot1, dot2) { - if (this.connections.length >= this.config.maxConnections) return; - - dot1.connectionCount++; - dot2.connectionCount++; - - this.connections.push({ - dot1: dot1, - dot2: dot2, - startTime: Date.now(), - duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min) + this.config.connectionDuration.min - }); - } - - updateConnections() { - this.connections = this.connections.filter(conn => { - const elapsed = Date.now() - conn.startTime; - if (elapsed > conn.duration) { - conn.dot1.connectionCount--; - conn.dot2.connectionCount--; - return false; - } - return true; - }); - } - - drawConnections() { - this.connections.forEach(conn => { - const { dot1, dot2, startTime, duration } = conn; - const elapsed = Date.now() - startTime; - const opacity = Math.min(1, elapsed / 500); - - const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, dot2.y); - gradient.addColorStop(0, this.config.colors.connection.start); - gradient.addColorStop(0.5, this.config.colors.connection.middle); - gradient.addColorStop(1, this.config.colors.connection.end); - - this.ctx.beginPath(); - this.ctx.moveTo(dot1.x, dot1.y); - this.ctx.lineTo(dot2.x, dot2.y); - - this.ctx.strokeStyle = gradient; - this.ctx.lineWidth = 2; - this.ctx.globalAlpha = opacity; - - const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2; - this.ctx.shadowBlur = 4 + pulse * 6; - this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)'; - - this.ctx.stroke(); - }); - this.ctx.globalAlpha = 1; - this.ctx.shadowBlur = 0; - } - - tryCreateConnections() { - for (let i = 0; i < this.dots.length; i++) { - for (let j = i + 1; j < this.dots.length; j++) { - if (Math.random() < this.config.connectionChance) { - const dot1 = this.dots[i]; - const dot2 = this.dots[j]; - - const dx = dot2.x - dot1.x; - const dy = dot2.y - dot1.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 150 && distance > 30) { - const alreadyConnected = this.connections.some(conn => - (conn.dot1 === dot1 && conn.dot2 === dot2) || - (conn.dot1 === dot2 && conn.dot2 === dot1) - ); - if (!alreadyConnected) { - this.createConnection(dot1, dot2); - } - } - } - } - } - } - - animate() { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - - if (!this.prefersReducedMotion) { - this.updateDots(); - this.updateConnections(); - this.tryCreateConnections(); - } - - this.drawConnections(); - this.drawDots(); - - this.rafId = requestAnimationFrame(() => this.animate()); - } -} - -document.addEventListener('DOMContentLoaded', () => { - new MatrixBackground(); -});