Compare commits

...
Author SHA1 Message Date
ivanch 870afc8b88 improving performance 2026-10-12 15:38:17 -03:00
ivanch 67335fb55b re-styling readme 2026-10-12 15:04:02 -03:00
ivanch e205172fce making it mobile friendly
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 42s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 5s
2026-10-11 16:48:40 -03:00
ivanch 6a93e9ae8d adding readme.md
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 11s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 4s
2026-09-09 22:28:18 -03:00
11 changed files with 408 additions and 136 deletions

No files matched your search

+4 -4
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="The requested page could not be found.">
<meta name="author" content="ivanch">
@@ -15,8 +15,8 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>404 | ivanch</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/matrix.css">
<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">
@@ -45,7 +45,7 @@
</section>
</main>
<script src="js/flowfield.js"></script>
<script src="js/flowfield.js?v=20261012-1"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
```text
_ _
(_)__ ____ _ _ __ ___| |__
| |\ \ / / _` | '_ \ / __| '_ \
| | \ V / (_| | | | | (__| | | |
|_| \_/ \__,_|_| |_|\___|_| |_|
software, servers, and a little sorcery.
```
**[Enter the site](https://ivanch.me)** · [GitHub](https://github.com/ivanch) · [Gitea](https://git.ivanch.me/ivanch) · [Blog](https://blog.ivanch.me)
## `$ whoami`
Hey, I'm **José**, usually found online as **ivanch**.
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.
```text
interests
|-- building robust software
|-- making CI/CD do the repetitive work
|-- orchestrating containers with Kubernetes
`-- self-hosting, tinkering, and open source
```
## `$ ls toolbox/`
| Shelf | Tools |
| :--- | :--- |
| Code | .NET Core, React, Python, HTML, CSS |
| Data | PostgreSQL, MySQL, Redis |
| Infrastructure | Linux, Bash, Docker, Kubernetes, Terraform |
| Delivery | GitHub Actions, Gitea Actions |
## `$ ls projects/`
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. [Visit the app](https://opencand.ivanch.me). |
| [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++. |
## `$ ssh homelab`
Meet **[Haven](https://github.com/ivanch/haven)**: my personal homelab,
where bare-metal Kubernetes meets Docker containers and the occasional experiment.
```text
.-------------------.
| Haven |
| bare metal / k3s |
'---------+---------'
|
.---------+---------.
| |
deployments server scripts
```
[Explore the deployments](https://github.com/ivanch/haven) · [Browse the scripts](https://git.ivanch.me/ivanch/server-scripts)
## `$ cat site.txt`
This repository is the source for **[ivanch.me](https://ivanch.me)**:
a static homepage with glass panels, a canvas flow field, and links to the things I build.
HTML, CSS, and vanilla JavaScript keep it small; Nginx serves it in production.
```text
.
|-- index.html # the front door
|-- 404.html # for the scenic detours
|-- assets/ # avatar and favicon
|-- css/ # layout, glass, and color tokens
|-- js/ # canvas backgrounds and social interactions
|-- Dockerfile # minify JavaScript, then serve with Nginx
|-- nginx.conf # caching, compression, and routing
`-- .gitea/workflows/ # build and deploy
```
## `$ cat contact.txt`
[LinkedIn](https://www.linkedin.com/in/joseivanch/) · [GitHub](https://github.com/ivanch) · [Gitea](https://git.ivanch.me/ivanch) · [Blog](https://blog.ivanch.me)
```text
ivanch@home:~$ _
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+23 -32
View File
@@ -16,7 +16,6 @@ body {
font-family: var(--font-family);
background: var(--background-color);
min-height: 100vh;
overflow-x: hidden;
position: relative;
color: var(--text-color);
-webkit-font-smoothing: antialiased;
@@ -60,18 +59,19 @@ 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;
}
.main-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
--page-gutter: var(--space-lg);
--page-top: var(--space-xl);
position: relative;
margin: 0 auto;
max-width: 1200px;
width: 100%;
padding: 0 var(--space-lg);
min-height: 100vh;
min-height: 100svh;
padding: var(--page-top) var(--page-gutter) var(--space-xl);
display: flex;
align-items: center;
justify-content: center;
@@ -114,8 +114,8 @@ img {
padding: var(--space-2xl) var(--space-xl);
transition: background var(--dur-med) var(--ease-out-soft),
box-shadow var(--dur-med) var(--ease-out-soft);
min-width: 26rem;
max-width: 26rem;
min-width: 0;
width: 100%;
}
/* ------------------------------------------------------------------ */
@@ -158,45 +158,36 @@ img {
/* ------------------------------------------------------------------ */
/* Mobile responsive layout */
/* Single breakpoint switches the centered card to a stacked column. */
/* Width constraints (set via tokens in section CSS) are overridden */
/* in one place here rather than repeated across multiple sections. */
/* Keep the page in normal flow so short screens can scroll fully. */
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.main-container {
position: relative;
top: auto;
left: auto;
transform: none;
padding: var(--space-xl) var(--space-md);
min-height: 100vh;
display: flex;
--page-gutter: var(--space-md);
align-items: flex-start;
}
.content,
.homelab-section,
.projects-section {
min-width: unset;
max-width: unset;
width: 100%;
}
.content {
padding: var(--space-xl) var(--space-lg);
}
}
@media (max-width: 480px) {
body {
font-size: 14px;
}
.main-container {
padding: var(--space-xl) var(--space-sm);
--page-gutter: var(--space-xs);
--page-top: var(--space-md);
}
.content {
padding: var(--space-lg) var(--space-md);
}
}
/* Leave room for notches and the home indicator in landscape, too. */
@supports (padding: max(0px, env(safe-area-inset-left))) {
.main-container {
padding-top: max(var(--page-top), env(safe-area-inset-top));
padding-left: max(var(--page-gutter), env(safe-area-inset-left));
padding-right: max(var(--page-gutter), env(safe-area-inset-right));
padding-bottom: max(var(--space-xl), env(safe-area-inset-bottom));
}
}
+32 -6
View File
@@ -8,14 +8,40 @@
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%);
}
@media (prefers-reduced-motion: reduce) {
.matrix-canvas {
filter: brightness(0.6) saturate(0.9);
/* CSS only on phones/tablets and for reduced motion: no canvas work. */
/* Keep this query in sync with js/flowfield.js. */
@media (max-width: 1024px), (pointer: coarse), (prefers-reduced-motion: reduce) {
body {
background:
radial-gradient(ellipse at 0% 0%, rgba(79, 195, 247, 0.18), transparent 55%),
radial-gradient(ellipse at 100% 65%, rgba(118, 75, 162, 0.22), transparent 60%),
linear-gradient(150deg, #0b1220, #100c1c);
}
.matrix-canvas,
.avatar .glitch-layer {
display: none;
}
.main-container .glass,
.avatar::after,
.homelab-background,
.homelab-background::after,
.homelab-title {
animation: none;
}
/* Avoid blur compositing work while scrolling. */
body .main-container :is(.glass, .content, .projects-section, .homelab-section, .btn) {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
+21 -23
View File
@@ -5,17 +5,18 @@
padding: var(--space-2xl);
width: 100%;
display: grid;
grid-template-columns: 200px 1fr;
grid-template-columns: 180px minmax(0, 1fr);
gap: var(--space-xl);
align-items: center;
animation: fadeInUp 0.8s var(--ease-out-expo);
}
.hero-details {
display: flex;
flex-direction: row;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
gap: var(--space-sm);
align-items: flex-start;
align-items: stretch;
min-width: 0;
width: 100%;
justify-content: flex-start;
}
@@ -153,7 +154,6 @@
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.hero-details {
flex-direction: column;
gap: var(--space-xl);
}
@@ -166,6 +166,10 @@
width: 100%;
align-items: stretch;
}
.tech-tags {
justify-content: center;
}
}
@media (max-width: 768px) {
@@ -176,8 +180,13 @@
text-align: center;
}
.hero-details {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-lg);
}
.name {
font-size: var(--text-xl);
font-size: var(--text-2xl);
}
.social-links {
@@ -191,35 +200,24 @@
padding: var(--space-lg) var(--space-md);
}
.name {
font-size: var(--text-lg);
}
.social-links {
gap: var(--space-xs);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
}
.social-link {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-xs);
font-size: var(--text-sm);
min-width: 0;
flex: 1;
max-width: calc(50% - 6px);
justify-content: center;
}
.social-link span {
display: none;
.social-link:last-child {
grid-column: 1 / -1;
}
.social-link i {
font-size: 16px;
}
}
@media (max-width: 380px) {
.social-link {
max-width: calc(100% / 2 - 6px);
padding: var(--space-sm);
}
}
+3 -5
View File
@@ -1,5 +1,4 @@
.homelab-section {
margin-top: var(--space-md);
padding: var(--space-xl) var(--space-lg);
background: rgba(20, 20, 30, 0.10);
backdrop-filter: blur(48px);
@@ -7,8 +6,8 @@
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-md), var(--inset-shadow);
border: 1px solid var(--glass-border);
min-width: 26rem;
max-width: 26rem;
min-width: 0;
width: 100%;
align-self: stretch;
display: flex;
flex-direction: column;
@@ -220,12 +219,11 @@
.homelab-buttons {
gap: var(--space-sm);
flex-direction: column;
align-items: center;
align-items: stretch;
}
.homelab-btn {
width: 100%;
max-width: 200px;
justify-content: center;
}
}
+22 -2
View File
@@ -262,6 +262,26 @@
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (min-width: 769px) and (max-width: 1024px) {
.profile-container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xl);
}
.profile-container .profile-info {
margin-top: 0;
align-items: flex-start;
text-align: left;
}
.profile-container .avatar {
margin: 0;
flex-shrink: 0;
}
}
@media (max-width: 1024px) {
.profile-info {
align-items: center;
@@ -304,7 +324,7 @@
}
.profile-info-item {
font-size: var(--text-xs);
font-size: var(--text-sm);
flex-direction: row;
gap: var(--space-xs);
}
@@ -327,6 +347,6 @@
}
.profile-info-item {
font-size: var(--text-xs);
font-size: var(--text-sm);
}
}
+17 -16
View File
@@ -1,5 +1,4 @@
.projects-section {
flex: 0 0 340px;
display: flex;
flex-direction: column;
gap: var(--space-md);
@@ -10,8 +9,8 @@
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-md), var(--inset-shadow);
border-bottom: 1px solid var(--glass-border);
min-width: 380px;
max-width: 440px;
min-width: 0;
width: 100%;
align-self: stretch;
}
@@ -112,13 +111,11 @@
flex-wrap: nowrap;
}
/* Keep long titles from pushing icons to a new line */
/* Let titles wrap while keeping the action links available. */
.project-title > span:first-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.project-icon-link {
@@ -150,12 +147,16 @@
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.projects-section {
flex: unset;
max-width: 100%;
margin-left: 0;
margin-top: var(--space-xl);
min-width: unset;
.project-icon-link {
width: 44px;
height: 44px;
}
}
@media (pointer: coarse) {
.project-icon-link {
width: 44px;
height: 44px;
}
}
@@ -179,7 +180,7 @@
}
.projects-title {
font-size: var(--text-sm);
font-size: var(--text-md);
}
.project {
@@ -191,7 +192,7 @@
}
.project-desc {
font-size: var(--text-xs);
font-size: var(--text-sm);
}
.project-icons {
@@ -205,7 +206,7 @@
}
.project-title {
font-size: var(--text-xs);
font-size: var(--text-sm);
flex-direction: row;
align-items: center;
gap: var(--space-xs);
+9 -9
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="ivanch's personal portfolio showcasing projects and homelab setup.">
<meta name="author" content="ivanch">
@@ -14,14 +14,14 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>ivanch</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/main.css?v=20261012-1">
<link rel="stylesheet" href="css/sections/profile.css">
<link rel="stylesheet" href="css/sections/homelab.css">
<link rel="stylesheet" href="css/sections/projects.css">
<link rel="stylesheet" href="css/sections/hero.css">
<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">
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/button.css">
<!-- Remix Icon CDN for social icons -->
@@ -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"></script>
<script src="js/flowfield.js?v=20261012-1"></script>
<script src="js/social.js"></script>
</body>
</html>
+186 -39
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. */
@@ -116,7 +122,10 @@ class FlowFieldBackground {
this.sparks = [];
this.mouse = { x: 0, y: 0, active: false };
this.rafId = null;
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.resizeRaf = null;
this.lastFrame = 0;
this.frameInterval = 1000 / 60;
this.events = new AbortController();
this.width = 0;
this.height = 0;
@@ -150,54 +159,84 @@ 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();
if (this.prefersReducedMotion) {
// Single long-exposure render; no animation loop at all.
this.warmup(180);
return;
}
// Pre-run a short exposure so the first paint is already textured.
this.warmup(60);
this.animate();
}
bindEvents() {
const signal = this.events.signal;
// The canvas has pointer-events: none, so listen on window.
window.addEventListener('pointermove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.mouse.active = true;
}, { passive: true });
}, { passive: true, signal });
window.addEventListener('pointerdown', (e) => {
this.spawnClickBurst(e.clientX, e.clientY);
}, { passive: true });
}, { passive: true, signal });
window.addEventListener('pointerout', () => {
this.mouse.active = false;
});
}, { signal });
// Pause the rAF loop when the tab is hidden to save CPU / battery.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.stop();
} else if (!this.prefersReducedMotion) {
} else {
this.start();
}
});
}, { signal });
let resizeRaf = null;
window.addEventListener('resize', () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => this.handleResize());
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = null;
this.handleResize();
});
}, { signal });
}
start() {
@@ -212,6 +251,17 @@ class FlowFieldBackground {
}
}
destroy() {
this.stop();
this.events.abort();
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
this.particles = [];
this.sparks = [];
// Release the high-DPI drawing buffer when switching to CSS only.
this.canvas.width = 1;
this.canvas.height = 1;
}
handleResize() {
const w = window.innerWidth;
const h = window.innerHeight;
@@ -222,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);
@@ -234,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)
@@ -280,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,
@@ -305,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,
@@ -313,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,
@@ -432,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;
}
}
@@ -497,12 +621,35 @@ 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());
}
}
document.addEventListener('DOMContentLoaded', () => {
new FlowFieldBackground();
// Keep this query in sync with the static backdrop in css/matrix.css.
const staticBackground = window.matchMedia(
'(max-width: 1024px), (pointer: coarse), (prefers-reduced-motion: reduce)'
);
let background = null;
function updateBackground() {
if (staticBackground.matches) {
if (background) background.destroy();
background = null;
} else if (!background) {
background = new FlowFieldBackground();
if (document.hidden) background.stop();
}
}
staticBackground.addEventListener('change', updateBackground);
updateBackground();
});