Merge pull request 'improving website and optimizing readme' (#4) from feat/new-style into main
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 37s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 6s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
ivanch committed 2026-10-13 12:49:11 +00:00
commit c232eee1e8
11 files changed
+238 -417

No files matched your search

+5 -5
View File
@@ -15,13 +15,13 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>404 | ivanch</title>
<link rel="stylesheet" href="css/main.css?v=20261011-2">
<link rel="stylesheet" href="css/matrix.css?v=20261011-2">
<link rel="stylesheet" href="css/main.css?v=20261012-1">
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/button.css">
<link rel="stylesheet" href="css/sections/error.css">
<!-- Remix Icon CDN for the navigation icon -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<!-- Remix Icon subset (only the icon used on this page) -->
<link rel="stylesheet" href="css/icons.css">
</head>
<body>
@@ -45,7 +45,7 @@
</section>
</main>
<script src="js/flowfield.js?v=20261011-2"></script>
<script src="js/flowfield.js?v=20261012-1"></script>
</body>
</html>
+43 -55
View File
@@ -1,68 +1,56 @@
<h1 align="center">
Hey there, I'm José 👋
</h1>
```text
_ _
(_)__ ____ _ _ __ ___| |__
| |\ \ / / _` | '_ \ / __| '_ \
| | \ V / (_| | | | | (__| | | |
|_| \_/ \__,_|_| |_|\___|_| |_|
<p align="center"> .NET Core | DevOps | Self-hosted Enthusiast </p>
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.
---
<p align="center" style="margin-top: 25px;">
<img src="https://img.shields.io/badge/.NET_Core-512BD4?logo=.net&logoColor=white" alt=".NET Core"/>
<img src="https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB" alt="React"/>
<img src="https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white" alt="Python"/>
<img src="https://img.shields.io/badge/HTML5-E34F26?logo=html5&logoColor=white" alt="HTML5"/>
<img src="https://img.shields.io/badge/CSS3-1572B6?logo=css3&logoColor=white" alt="CSS3"/>
<br>
<img src="https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL"/>
<img src="https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white" alt="MySQL"/>
<img src="https://img.shields.io/badge/Redis-DC382D?logo=redis&logoColor=white" alt="Redis"/>
<br>
<img src="https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black" alt="Linux"/>
<img src="https://img.shields.io/badge/Bash-4EAA25?logo=gnubash&logoColor=white" alt="Bash"/>
<img src="https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white" alt="Docker"/>
<img src="https://img.shields.io/badge/Kubernetes-326CE5?logo=kubernetes&logoColor=white" alt="Kubernetes"/>
<img src="https://img.shields.io/badge/Terraform-623CE4?logo=terraform&logoColor=white" alt="Terraform"/>
<img src="https://img.shields.io/badge/GitHub_Actions-2088FF?logo=github-actions&logoColor=white" alt="GitHub Actions"/>
<p align="center">
<a href="https://ivanch.me"><img alt="Website" src="https://img.shields.io/badge/Website-ivanch.me-4FC3F7?style=for-the-badge&logo=googlechrome&logoColor=white"></a>
<a href="https://github.com/ivanch"><img alt="GitHub" src="https://img.shields.io/badge/GitHub-ivanch-181717?style=for-the-badge&logo=github&logoColor=white"></a>
<a href="https://git.ivanch.me/ivanch"><img alt="Gitea" src="https://img.shields.io/badge/-Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white"></a>
<a href="https://blog.ivanch.me"><img alt="Blog" src="https://img.shields.io/badge/-Blog-667EEA?style=for-the-badge"></a>
<a href="https://www.linkedin.com/in/joseivanch/"><img alt="LinkedIn" src="https://img.shields.io/badge/-LinkedIn-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white"></a>
</p>
---
<h3 align="center"> 🚀 Featured Projects </h3>
## `$ 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/`
<!--
---
| Shelf | Tools |
| :--- | :--- |
| **Code** | ![.NET](https://img.shields.io/badge/.NET-512BD4?style=flat-square&logo=dotnet&logoColor=white) ![React](https://img.shields.io/badge/React-20232A?style=flat-square&logo=react&logoColor=61DAFB) ![Python](https://img.shields.io/badge/Python-3776AB?style=flat-square&logo=python&logoColor=white) ![HTML](https://img.shields.io/badge/HTML-E34F26?style=flat-square&logo=html5&logoColor=white) ![CSS](https://img.shields.io/badge/CSS-1572B6?style=flat-square&logo=css3&logoColor=white) |
| **Data** | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?style=flat-square&logo=postgresql&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-4479A1?style=flat-square&logo=mysql&logoColor=white) ![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat-square&logo=redis&logoColor=white) |
| **Infrastructure** | ![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black) ![Bash](https://img.shields.io/badge/Bash-4EAA25?style=flat-square&logo=gnubash&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white) ![Kubernetes](https://img.shields.io/badge/Kubernetes-326CE5?style=flat-square&logo=kubernetes&logoColor=white) ![Terraform](https://img.shields.io/badge/Terraform-7B42BC?style=flat-square&logo=terraform&logoColor=white) |
| **Delivery** | ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-2088FF?style=flat-square&logo=githubactions&logoColor=white) ![Gitea Actions](https://img.shields.io/badge/Gitea%20Actions-609926?style=flat-square&logo=gitea&logoColor=white) |
<h3 align="center"> 🌐 Contacts and socials </h3>
## `$ ls projects/`
<p align="center">
<a href="https://www.linkedin.com/in/joseivanch/">
<img src="https://img.shields.io/badge/LinkedIn-0A66C2?logo=linkedin&logoColor=white" alt="LinkedIn"/>
</a>
<a href="https://last.fm/user/ivanch">
<img src="https://img.shields.io/badge/Last.fm-D51007?logo=lastdotfm&logoColor=white" alt="Last.fm"/>
</a>
<a href="https://git.ivanch.me/ivanch">
<img src="https://img.shields.io/badge/Gitea-007300?logo=gitea&logoColor=white" alt="Gitea"/>
</a>
<a href="mailto:jose.henrique.ivan@gmail.com">
<img src="https://img.shields.io/badge/Email-333333?logo=gmail&logoColor=white" alt="Email"/>
</a>
<a href="https://blog.ivanch.me/">
<img src="https://img.shields.io/badge/Blog-9aa500?logo=blogger&logoColor=white" alt="Blog"/>
</a>
</p> -->
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!) |
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+35
View File
@@ -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"; }
-1
View File
@@ -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;
}
+5 -7
View File
@@ -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;
+6 -6
View File
@@ -14,18 +14,18 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>ivanch</title>
<link rel="stylesheet" href="css/main.css?v=20261011-2">
<link rel="stylesheet" href="css/main.css?v=20261012-1">
<link rel="stylesheet" href="css/sections/profile.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/homelab.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/projects.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/hero.css?v=20261011-2">
<link rel="stylesheet" href="css/matrix.css?v=20261011-2">
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/button.css">
<!-- Remix Icon CDN for social icons -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<!-- Remix Icon subset (only the icons used on this page) -->
<link rel="stylesheet" href="css/icons.css">
</head>
<body>
<canvas class="matrix-canvas" id="matrixCanvas"></canvas>
@@ -34,7 +34,7 @@
<div class="glass hero-card">
<div class="profile-container">
<div class="avatar">
<img src="/assets/lain.png" alt="Avatar" />
<img src="/assets/lain.jpg" alt="Avatar" width="320" height="320" decoding="async" />
</div>
<div class="profile-info">
<div class="profile-info-item">
@@ -149,7 +149,7 @@
</div>
</main>
<script src="js/flowfield.js?v=20261011-2"></script>
<script src="js/flowfield.js?v=20261012-1"></script>
<script src="js/social.js"></script>
</body>
</html>
+144 -23
View File
@@ -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;
const w = Math.min(widthLevels - 1, Math.round(p.width / widthStep));
lineKeys[i] = (p.tone * widthLevels + w) * alphaLevels + a;
// A subtle bright dot at the head of foreground particles
if (p.depth === 2 && (p.glow > 0.1 || Math.random() < 0.12)) {
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;
}
// Draw 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)})`;
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());
}
}
-320
View File
@@ -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();
});