improving website and optimizing readme #4

Merged
ivanch merged 7 commits from feat/new-style into main 2026-10-13 12:49:13 +00:00
11 changed files with 238 additions and 417 deletions

No files matched your search

+5 -5
View File
@@ -15,13 +15,13 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon"> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>404 | ivanch</title> <title>404 | 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/matrix.css?v=20261011-2"> <link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/button.css"> <link rel="stylesheet" href="css/button.css">
<link rel="stylesheet" href="css/sections/error.css"> <link rel="stylesheet" href="css/sections/error.css">
<!-- Remix Icon CDN for the navigation icon --> <!-- Remix Icon subset (only the icon used on this page) -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link rel="stylesheet" href="css/icons.css">
</head> </head>
<body> <body>
@@ -45,7 +45,7 @@
</section> </section>
</main> </main>
<script src="js/flowfield.js?v=20261011-2"></script> <script src="js/flowfield.js?v=20261012-1"></script>
</body> </body>
</html> </html>
+43 -55
View File
@@ -1,68 +1,56 @@
<h1 align="center"> ```text
Hey there, I'm José 👋 _ _
</h1> (_)__ ____ _ _ __ ___| |__
| |\ \ / / _` | '_ \ / __| '_ \
| | \ 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. <p align="center">
Passionate about building robust code, streamlining CI/CD, orchestrating Kubernetes, and contributing to open-source. <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>
<p align="center" style="margin-top: 25px;"> <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>
<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> </p>
--- ---
<h3 align="center"> 🚀 Featured Projects </h3> ## `$ whoami`
- 🔍 **[OpenCand](https://github.com/ivanch/opencand)** - [opencand.ivanch.me](https://opencand.ivanch.me) Hey, I'm **José**, usually found online as **ivanch**.
Simple Brazilian candidate data viewer and searcher, made with .NET Core and React
- 📃 **[Vision Start](https://github.com/ivanch/vision-start)** I build software with .NET, automate the path from commit to deployment,
A glassmorphism-looking like, modern and customizable Chrome startpage 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* ```text
Bare-metal Kubernetes cluster using k3s, some other Docker containers, and some sorcery 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)** ## `$ ls toolbox/`
Discord bot inspired by a Brazilian DJ
<!-- | 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 few things I've sent out into the world:
<a href="https://www.linkedin.com/in/joseivanch/">
<img src="https://img.shields.io/badge/LinkedIn-0A66C2?logo=linkedin&logoColor=white" alt="LinkedIn"/> | Project | What it does |
</a> | :--- | :--- |
<a href="https://last.fm/user/ivanch"> | **[OpenCand](https://github.com/ivanch/opencand)** | Search and explore Brazilian candidate data with .NET Core and React |
<img src="https://img.shields.io/badge/Last.fm-D51007?logo=lastdotfm&logoColor=white" alt="Last.fm"/> | **[Vision Start](https://github.com/ivanch/vision-start)** | A customizable Chrome startpage with a glassmorphism look |
</a> | **[Kasbot](https://github.com/ivanch/kasbot)** | A Discord bot inspired by a Brazilian DJ |
<a href="https://git.ivanch.me/ivanch"> | **[Candlebright](https://github.com/ivanch/candlebright)** | A game project written in C++ |
<img src="https://img.shields.io/badge/Gitea-007300?logo=gitea&logoColor=white" alt="Gitea"/> | **[Haven](https://github.com/ivanch/haven)** | My personal homelab (now with GitOps!) |
</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> -->
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-top: 1px solid var(--glass-border);
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
box-shadow: var(--shadow-lg), var(--inset-shadow); box-shadow: var(--shadow-lg), var(--inset-shadow);
will-change: box-shadow;
animation: glass-shadow 10s ease-in-out infinite; animation: glass-shadow 10s ease-in-out infinite;
} }
+5 -7
View File
@@ -8,10 +8,12 @@
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.88); 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: background-image:
radial-gradient(120% 100% at 50% 0%, rgba(20, 24, 48, 0.55) 0%, rgba(0, 0, 0, 0) 60%), 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(18, 18, 38, 0.72) 100%); linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(13, 13, 27, 0.72) 100%);
filter: brightness(0.7) saturate(1.05);
} }
/* CSS only on phones/tablets and for reduced motion: no canvas work. */ /* CSS only on phones/tablets and for reduced motion: no canvas work. */
@@ -37,10 +39,6 @@
animation: none; animation: none;
} }
.main-container .glass {
will-change: auto;
}
/* Avoid blur compositing work while scrolling. */ /* Avoid blur compositing work while scrolling. */
body .main-container :is(.glass, .content, .projects-section, .homelab-section, .btn) { body .main-container :is(.glass, .content, .projects-section, .homelab-section, .btn) {
backdrop-filter: none; backdrop-filter: none;
+6 -6
View File
@@ -14,18 +14,18 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon"> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>ivanch</title> <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/profile.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/homelab.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/projects.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/hero.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"> <link rel="stylesheet" href="css/button.css">
<!-- Remix Icon CDN for social icons --> <!-- Remix Icon subset (only the icons used on this page) -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link rel="stylesheet" href="css/icons.css">
</head> </head>
<body> <body>
<canvas class="matrix-canvas" id="matrixCanvas"></canvas> <canvas class="matrix-canvas" id="matrixCanvas"></canvas>
@@ -34,7 +34,7 @@
<div class="glass hero-card"> <div class="glass hero-card">
<div class="profile-container"> <div class="profile-container">
<div class="avatar"> <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>
<div class="profile-info"> <div class="profile-info">
<div class="profile-info-item"> <div class="profile-info-item">
@@ -149,7 +149,7 @@
</div> </div>
</main> </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> <script src="js/social.js"></script>
</body> </body>
</html> </html>
+144 -23
View File
@@ -10,6 +10,12 @@
* Trails are drawn by erasing the previous frame with a low-alpha * Trails are drawn by erasing the previous frame with a low-alpha
* `destination-out` pass, so the CSS gradient painted behind the canvas * `destination-out` pass, so the CSS gradient painted behind the canvas
* element keeps showing through instead of being flooded by an opaque fill. * 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. */ /* 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.mouse = { x: 0, y: 0, active: false };
this.rafId = null; this.rafId = null;
this.resizeRaf = null; this.resizeRaf = null;
this.lastFrame = 0;
this.frameInterval = 1000 / 60;
this.events = new AbortController(); this.events = new AbortController();
this.width = 0; this.width = 0;
@@ -151,9 +159,42 @@ class FlowFieldBackground {
{ weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet { weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet
]; ];
this.buildStyleCache();
this.init(); 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() { init() {
this.bindEvents(); this.bindEvents();
this.handleResize(); this.handleResize();
@@ -231,7 +272,9 @@ class FlowFieldBackground {
this.width = w; this.width = w;
this.height = h; 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.width = Math.round(w * this.dpr);
this.canvas.height = Math.round(h * this.dpr); this.canvas.height = Math.round(h * this.dpr);
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
@@ -243,21 +286,28 @@ class FlowFieldBackground {
this.particles.push(this.createParticle()); 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. // Repaint a short exposure so a resize never flashes an empty canvas.
this.warmup(30); this.warmup(30);
} }
pickTone() { pickTone() {
let roll = Math.random(); let roll = Math.random();
for (const tone of this.palette) { for (let i = 0; i < this.palette.length; i++) {
roll -= tone.weight; roll -= this.palette[i].weight;
if (roll <= 0) return tone; if (roll <= 0) return i;
} }
return this.palette[0]; return 0;
} }
createParticle() { createParticle() {
const tone = this.pickTone(); const toneIndex = this.pickTone();
const tone = this.palette[toneIndex];
const { min, max } = this.config.life; const { min, max } = this.config.life;
// 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight) // 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight)
@@ -289,7 +339,7 @@ class FlowFieldBackground {
vy: 0, vy: 0,
speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5), speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5),
width: (0.7 + Math.random() * 0.5) * widthMult, width: (0.7 + Math.random() * 0.5) * widthMult,
rgb: tone.rgb, tone: toneIndex,
baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult, baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult,
alpha: 0, alpha: 0,
glow: 0, glow: 0,
@@ -314,7 +364,7 @@ class FlowFieldBackground {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const speed = 1.0 + Math.random() * 2.2; const speed = 1.0 + Math.random() * 2.2;
const tone = this.pickTone(); const tone = this.palette[this.pickTone()];
this.sparks.push({ this.sparks.push({
x, x,
y, y,
@@ -322,7 +372,7 @@ class FlowFieldBackground {
py: y, py: y,
vx: Math.cos(angle) * speed, vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed, vy: Math.sin(angle) * speed,
rgb: tone.rgb, rgb: tone.drawRgb,
size: 0.6 + Math.random() * 0.8, size: 0.6 + Math.random() * 0.8,
alpha: 0.6 + Math.random() * 0.25, alpha: 0.6 + Math.random() * 0.25,
life: 0, life: 0,
@@ -441,34 +491,99 @@ class FlowFieldBackground {
this.ctx.fillRect(0, 0, this.width, this.height); 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() { drawParticles() {
const ctx = this.ctx; const ctx = this.ctx;
// Additive blending gives a soft glow where currents overlap. const particles = this.particles;
ctx.globalCompositeOperation = 'lighter'; const n = particles.length;
ctx.lineCap = 'round'; 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) { if (p.fresh) {
p.fresh = false; p.fresh = false;
continue; continue;
} }
const alpha = Math.min(1, p.alpha + p.glow * this.config.glowBoost); const alpha = Math.min(1, p.alpha + p.glow * glowBoost);
if (alpha <= 0.005) continue; const a = Math.round(alpha / alphaStep);
if (a === 0) continue;
ctx.strokeStyle = `rgba(${p.rgb}, ${alpha.toFixed(3)})`; const w = Math.min(widthLevels - 1, Math.round(p.width / widthStep));
ctx.lineWidth = p.width; 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(); ctx.beginPath();
for (let j = start; j < end; j++) {
const p = particles[lineOrder[j]];
ctx.moveTo(p.px, p.py); ctx.moveTo(p.px, p.py);
ctx.lineTo(p.x, p.y); ctx.lineTo(p.x, p.y);
}
ctx.stroke(); ctx.stroke();
start = end;
}
// Draw a subtle bright dot at the head of foreground particles const dotCounts = this.dotCounts;
if (p.depth === 2 && (p.glow > 0.1 || Math.random() < 0.12)) { const dotOrder = this.dotOrder;
ctx.fillStyle = `rgba(240, 248, 255, ${(alpha * 0.75).toFixed(3)})`; 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.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(); 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.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();
});