Compare commits

..
Author SHA1 Message Date
ivanch ec62aa03ba trying to optmize
Homepage Build and Deploy (Test) / Deploy Homepage (pull_request) Successful in 9s
Homepage Build and Deploy (Test) / Build Homepage Image (pull_request) Successful in 52s
2026-07-10 19:09:32 -03:00
18 changed files with 439 additions and 929 deletions

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ env:
jobs:
build:
name: Build Homepage Image
runs-on: runner-slim-amd64
runs-on: ubuntu-amd64
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
steps:
@@ -33,7 +33,7 @@ jobs:
deploy:
name: Deploy Homepage
runs-on: runner-slim-amd64
runs-on: ubuntu-amd64
needs: build
steps:
- name: Check out repository
+2 -2
View File
@@ -15,7 +15,7 @@ env:
jobs:
build:
name: Build Homepage Image
runs-on: runner-slim-amd64
runs-on: ubuntu-amd64
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
steps:
@@ -39,7 +39,7 @@ jobs:
deploy:
name: Deploy Homepage
runs-on: runner-slim-amd64
runs-on: ubuntu-amd64
needs: build
steps:
- name: Check out repository
+7 -7
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="description" content="The requested page could not be found.">
<meta name="author" content="ivanch">
@@ -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=20261012-1">
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/matrix.css">
<link rel="stylesheet" href="css/button.css">
<link rel="stylesheet" href="css/sections/error.css">
<!-- Remix Icon subset (only the icon used on this page) -->
<link rel="stylesheet" href="css/icons.css">
<!-- Remix Icon CDN for the navigation icon -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
</head>
<body>
@@ -45,7 +45,7 @@
</section>
</main>
<script src="js/flowfield.js?v=20261012-1"></script>
<script src="js/matrix.js"></script>
</body>
</html>
</html>
+11 -6
View File
@@ -4,16 +4,21 @@ WORKDIR /src
COPY . /src
# Install terser to minify JS files
RUN npm install -g terser
RUN if [ -d "js" ]; then find js -type f -name "*.js" -exec sh -c 'for f; do terser "$f" -c -m -o "$f"; done' sh {} +; fi
# Build a production-only copy so the source tree remains useful for local
# development and the final image contains no unminified source assets.
RUN npm install --global --no-audit --no-fund --no-update-notifier \
terser@5.49.0 clean-css-cli@5.6.3 html-minifier-terser@7.2.0 \
&& mkdir -p /src/dist/css /src/dist/js \
&& find css -type f -name '*.css' -exec sh -c 'for file do target="/src/dist/$file"; mkdir -p "$(dirname "$target")"; cleancss -O 1 --output "$target" "$file"; done' sh {} + \
&& find js -type f -name '*.js' -exec sh -c 'for file do target="/src/dist/$file"; mkdir -p "$(dirname "$target")"; terser "$file" --compress --mangle --comments false --output "$target"; done' sh {} + \
&& find . -maxdepth 1 -type f -name '*.html' -exec sh -c 'for file do target="/src/dist/$file"; html-minifier-terser --collapse-whitespace --remove-comments --remove-redundant-attributes --use-short-doctype --minify-css=true --minify-js=true "$file" --output "$target"; done' sh {} + \
&& cp -R assets /src/dist/assets
FROM nginx:alpine-slim AS final
COPY nginx.conf /etc/nginx/nginx.conf
COPY . /usr/share/nginx/html
COPY --from=build /src/js /usr/share/nginx/html/js
COPY --from=build /src/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
-91
View File
@@ -1,91 +0,0 @@
```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:~$ _
```
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

-35
View File
@@ -1,35 +0,0 @@
/*
* 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"; }
+32 -23
View File
@@ -16,6 +16,7 @@ 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;
@@ -59,19 +60,18 @@ 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 {
--page-gutter: var(--space-lg);
--page-top: var(--space-xl);
position: relative;
margin: 0 auto;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 1200px;
width: 100%;
min-height: 100vh;
min-height: 100svh;
padding: var(--page-top) var(--page-gutter) var(--space-xl);
padding: 0 var(--space-lg);
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: 0;
width: 100%;
min-width: 26rem;
max-width: 26rem;
}
/* ------------------------------------------------------------------ */
@@ -158,36 +158,45 @@ img {
/* ------------------------------------------------------------------ */
/* Mobile responsive layout */
/* Keep the page in normal flow so short screens can scroll fully. */
/* 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. */
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.main-container {
--page-gutter: var(--space-md);
position: relative;
top: auto;
left: auto;
transform: none;
padding: var(--space-xl) var(--space-md);
min-height: 100vh;
display: flex;
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 {
--page-gutter: var(--space-xs);
--page-top: var(--space-md);
padding: var(--space-xl) var(--space-sm);
}
.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));
}
}
+6 -32
View File
@@ -8,40 +8,14 @@
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(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%);
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);
}
/* 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;
@media (prefers-reduced-motion: reduce) {
.matrix-canvas {
filter: brightness(0.6) saturate(0.9);
}
}
+23 -21
View File
@@ -5,18 +5,17 @@
padding: var(--space-2xl);
width: 100%;
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
grid-template-columns: 200px 1fr;
gap: var(--space-xl);
align-items: center;
animation: fadeInUp 0.8s var(--ease-out-expo);
}
.hero-details {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
display: flex;
flex-direction: row;
gap: var(--space-sm);
align-items: stretch;
min-width: 0;
align-items: flex-start;
width: 100%;
justify-content: flex-start;
}
@@ -154,6 +153,7 @@
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.hero-details {
flex-direction: column;
gap: var(--space-xl);
}
@@ -166,10 +166,6 @@
width: 100%;
align-items: stretch;
}
.tech-tags {
justify-content: center;
}
}
@media (max-width: 768px) {
@@ -180,13 +176,8 @@
text-align: center;
}
.hero-details {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-lg);
}
.name {
font-size: var(--text-2xl);
font-size: var(--text-xl);
}
.social-links {
@@ -200,24 +191,35 @@
padding: var(--space-lg) var(--space-md);
}
.name {
font-size: var(--text-lg);
}
.social-links {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
gap: var(--space-xs);
}
.social-link {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
font-size: var(--text-xs);
min-width: 0;
flex: 1;
max-width: calc(50% - 6px);
justify-content: center;
}
.social-link:last-child {
grid-column: 1 / -1;
.social-link span {
display: none;
}
.social-link i {
font-size: 16px;
}
}
@media (max-width: 380px) {
.social-link {
max-width: calc(100% / 2 - 6px);
padding: var(--space-sm);
}
}
+5 -3
View File
@@ -1,4 +1,5 @@
.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);
@@ -6,8 +7,8 @@
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-md), var(--inset-shadow);
border: 1px solid var(--glass-border);
min-width: 0;
width: 100%;
min-width: 26rem;
max-width: 26rem;
align-self: stretch;
display: flex;
flex-direction: column;
@@ -219,11 +220,12 @@
.homelab-buttons {
gap: var(--space-sm);
flex-direction: column;
align-items: stretch;
align-items: center;
}
.homelab-btn {
width: 100%;
max-width: 200px;
justify-content: center;
}
}
+3 -23
View File
@@ -262,26 +262,6 @@
/* ------------------------------------------------------------------ */
/* 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;
@@ -324,7 +304,7 @@
}
.profile-info-item {
font-size: var(--text-sm);
font-size: var(--text-xs);
flex-direction: row;
gap: var(--space-xs);
}
@@ -347,6 +327,6 @@
}
.profile-info-item {
font-size: var(--text-sm);
font-size: var(--text-xs);
}
}
}
+16 -17
View File
@@ -1,4 +1,5 @@
.projects-section {
flex: 0 0 340px;
display: flex;
flex-direction: column;
gap: var(--space-md);
@@ -9,8 +10,8 @@
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-md), var(--inset-shadow);
border-bottom: 1px solid var(--glass-border);
min-width: 0;
width: 100%;
min-width: 380px;
max-width: 440px;
align-self: stretch;
}
@@ -111,11 +112,13 @@
flex-wrap: nowrap;
}
/* Let titles wrap while keeping the action links available. */
/* Keep long titles from pushing icons to a new line */
.project-title > span:first-child {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-icon-link {
@@ -147,16 +150,12 @@
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.project-icon-link {
width: 44px;
height: 44px;
}
}
@media (pointer: coarse) {
.project-icon-link {
width: 44px;
height: 44px;
.projects-section {
flex: unset;
max-width: 100%;
margin-left: 0;
margin-top: var(--space-xl);
min-width: unset;
}
}
@@ -180,7 +179,7 @@
}
.projects-title {
font-size: var(--text-md);
font-size: var(--text-sm);
}
.project {
@@ -192,7 +191,7 @@
}
.project-desc {
font-size: var(--text-sm);
font-size: var(--text-xs);
}
.project-icons {
@@ -206,7 +205,7 @@
}
.project-title {
font-size: var(--text-sm);
font-size: var(--text-xs);
flex-direction: row;
align-items: center;
gap: var(--space-xs);
+12 -12
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, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="description" content="ivanch's personal portfolio showcasing projects and homelab setup.">
<meta name="author" content="ivanch">
@@ -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=20261012-1">
<link rel="stylesheet" href="css/main.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/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/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/matrix.css">
<link rel="stylesheet" href="css/button.css">
<!-- Remix Icon subset (only the icons used on this page) -->
<link rel="stylesheet" href="css/icons.css">
<!-- Remix Icon CDN for social icons -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
</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.jpg" alt="Avatar" width="320" height="320" decoding="async" />
<img src="/assets/lain.png" alt="Avatar" />
</div>
<div class="profile-info">
<div class="profile-info-item">
@@ -149,7 +149,7 @@
</div>
</main>
<script src="js/flowfield.js?v=20261012-1"></script>
<script src="js/matrix.js"></script>
<script src="js/social.js"></script>
</body>
</html>
</html>
-655
View File
@@ -1,655 +0,0 @@
/**
* FlowFieldBackground
*
* Minimal generative backdrop: hundreds of hair-thin particles ride a
* domain-warped simplex flow field and leave short silk-like trails.
* The cursor bends the current into a slow vortex (nearby strokes pick up
* a faint glow); clicking / tapping emits a thin ripple that shoves
* particles aside.
*
* 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. */
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* Compact seeded 2D simplex noise (Gustavson-style), output in ~[-1, 1]. */
class SimplexNoise {
constructor(seed) {
const rand = mulberry32(seed);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
// Fisher-Yates shuffle for the permutation table.
for (let i = 255; i > 0; i--) {
const j = (rand() * (i + 1)) | 0;
const tmp = p[i];
p[i] = p[j];
p[j] = tmp;
}
this.perm = new Uint8Array(512);
this.permMod12 = new Uint8Array(512);
for (let i = 0; i < 512; i++) {
this.perm[i] = p[i & 255];
this.permMod12[i] = this.perm[i] % 12;
}
}
noise2D(xin, yin) {
const F2 = SimplexNoise.F2;
const G2 = SimplexNoise.G2;
const GRAD3 = SimplexNoise.GRAD3;
let n0 = 0, n1 = 0, n2 = 0;
const s = (xin + yin) * F2;
const i = Math.floor(xin + s);
const j = Math.floor(yin + s);
const t = (i + j) * G2;
const x0 = xin - (i - t);
const y0 = yin - (j - t);
// Corner offsets depend on which half of the simplex we landed in.
let i1, j1;
if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; }
const x1 = x0 - i1 + G2;
const y1 = y0 - j1 + G2;
const x2 = x0 - 1 + 2 * G2;
const y2 = y0 - 1 + 2 * G2;
const ii = i & 255;
const jj = j & 255;
let t0 = 0.5 - x0 * x0 - y0 * y0;
if (t0 >= 0) {
const g = GRAD3[this.permMod12[ii + this.perm[jj]]];
t0 *= t0;
n0 = t0 * t0 * (g[0] * x0 + g[1] * y0);
}
let t1 = 0.5 - x1 * x1 - y1 * y1;
if (t1 >= 0) {
const g = GRAD3[this.permMod12[ii + i1 + this.perm[jj + j1]]];
t1 *= t1;
n1 = t1 * t1 * (g[0] * x1 + g[1] * y1);
}
let t2 = 0.5 - x2 * x2 - y2 * y2;
if (t2 >= 0) {
const g = GRAD3[this.permMod12[ii + 1 + this.perm[jj + 1]]];
t2 *= t2;
n2 = t2 * t2 * (g[0] * x2 + g[1] * y2);
}
// Empirical factor scaling the sum into roughly [-1, 1].
return 70 * (n0 + n1 + n2);
}
}
SimplexNoise.F2 = 0.5 * (Math.sqrt(3) - 1);
SimplexNoise.G2 = (3 - Math.sqrt(3)) / 6;
SimplexNoise.GRAD3 = [
[1, 1], [-1, 1], [1, -1], [-1, -1],
[1, 0], [-1, 0], [1, 0], [-1, 0],
[0, 1], [0, -1], [0, 1], [0, -1]
];
class FlowFieldBackground {
constructor() {
// Legacy element id kept from the previous background; the canvas
// and its CSS (css/matrix.css) are reused on purpose.
this.canvas = document.getElementById('matrixCanvas');
this.ctx = this.canvas.getContext('2d');
this.noise = new SimplexNoise((Math.random() * 0xffffffff) | 0);
this.particles = [];
this.sparks = [];
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;
this.height = 0;
this.dpr = 1;
// Random phase so each visit opens on a different current.
this.time = Math.random() * 4000;
this.config = {
baseSpeed: 0.75,
maxSpeed: 2.8, // velocity cap so streaks remain calm
steer: 0.055, // how fast particles align with the field
fieldScale: 0.0015, // noise zoom: small value = large, calm features
warpStrength: 160, // px of domain warp, keeps the flow organic
winding: 2.4, // field angle range multiplier (x PI)
timeDrift: 0.00028, // slow evolution of the whole field
fadeAlpha: 0.038, // trail persistence (lower = longer trails)
mouseRadius: 180,
mouseSwirl: 0.5, // tangential push around the cursor
mousePull: 0.06, // whisper of inward pull -> orbiting feel
glowBoost: 0.25, // extra alpha for particles near the cursor
sparkCount: 10, // subtle micro-sparks per click
life: { min: 280, max: 920 } // frames before a particle respawns
};
// Rich site palette with cyan, indigo, violet, and soft luminous accents
this.palette = [
{ weight: 0.55, rgb: '206, 224, 255', alpha: 0.13 }, // Soft cyan-white
{ weight: 0.18, rgb: '79, 195, 247', alpha: 0.24 }, // Electric cyan
{ weight: 0.14, rgb: '102, 126, 234', alpha: 0.16 }, // Deep indigo
{ 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();
// 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, signal });
window.addEventListener('pointerdown', (e) => {
this.spawnClickBurst(e.clientX, e.clientY);
}, { 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 {
this.start();
}
}, { signal });
window.addEventListener('resize', () => {
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = null;
this.handleResize();
});
}, { signal });
}
start() {
if (this.rafId) return;
this.animate();
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
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;
// Mobile browsers fire resize on scroll when the URL bar collapses;
// only react to real geometry changes to avoid canvas flicker.
if (this.width && w === this.width && Math.abs(h - this.height) < 150) return;
this.width = w;
this.height = h;
// 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);
// Density scales with area, clamped for both phones and ultrawides.
const count = Math.round(Math.min(Math.max((w * h) / 2600, 160), 650));
this.particles = [];
for (let i = 0; i < count; i++) {
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 (let i = 0; i < this.palette.length; i++) {
roll -= this.palette[i].weight;
if (roll <= 0) return i;
}
return 0;
}
createParticle() {
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)
const randDepth = Math.random();
let depth = 1;
let speedMult = 1.0;
let widthMult = 1.0;
let alphaMult = 1.0;
if (randDepth < 0.25) {
depth = 0;
speedMult = 0.65;
widthMult = 0.6;
alphaMult = 0.65;
} else if (randDepth > 0.78) {
depth = 2;
speedMult = 1.35;
widthMult = 1.35;
alphaMult = 1.25;
}
return {
depth,
x: Math.random() * this.width,
y: Math.random() * this.height,
px: 0,
py: 0,
vx: 0,
vy: 0,
speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5),
width: (0.7 + Math.random() * 0.5) * widthMult,
tone: toneIndex,
baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult,
alpha: 0,
glow: 0,
life: Math.floor(Math.random() * (min * 0.5)),
maxLife: min + Math.random() * (max - min),
fresh: true // no previous point yet -> nothing to draw
};
}
respawn(p) {
p.x = Math.random() * this.width;
p.y = Math.random() * this.height;
p.vx = 0;
p.vy = 0;
p.life = 0;
p.fresh = true;
}
spawnClickBurst(x, y) {
// Emit a subtle, delicate burst of hair-thin micro-sparks on click
const count = this.config.sparkCount;
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.palette[this.pickTone()];
this.sparks.push({
x,
y,
px: x,
py: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rgb: tone.drawRgb,
size: 0.6 + Math.random() * 0.8,
alpha: 0.6 + Math.random() * 0.25,
life: 0,
maxLife: 25 + Math.random() * 25
});
}
}
updateParticles() {
const { fieldScale, warpStrength, winding, steer, maxSpeed } = this.config;
const t = this.time;
const m = this.mouse;
const radius = this.config.mouseRadius;
const radiusSq = radius * radius;
const fadeIn = 35;
const fadeOut = 55;
for (const p of this.particles) {
// Smooth life envelope (fade-in on spawn, fade-out near maxLife)
let lifeEnvelope = 1;
if (p.life < fadeIn) {
lifeEnvelope = p.life / fadeIn;
} else if (p.life > p.maxLife - fadeOut) {
lifeEnvelope = Math.max(0, (p.maxLife - p.life) / fadeOut);
}
p.alpha = p.baseAlpha * lifeEnvelope;
// Domain-warped flow angle: the warp layer bends the main field
// so the current curls organically instead of drifting straight.
const warp = this.noise.noise2D(
p.x * fieldScale * 0.4 + t * 0.5,
p.y * fieldScale * 0.4 - t * 0.3
);
const angle = this.noise.noise2D(
(p.x + warp * warpStrength) * fieldScale + t,
(p.y - warp * warpStrength) * fieldScale - t * 0.7
) * Math.PI * winding;
p.vx += (Math.cos(angle) * p.speed - p.vx) * steer;
p.vy += (Math.sin(angle) * p.speed - p.vy) * steer;
// Cursor vortex: tangential swirl + slight inward pull.
let proximity = 0;
if (m.active) {
const dx = p.x - m.x;
const dy = p.y - m.y;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq && distSq > 0.5) {
const dist = Math.sqrt(distSq);
proximity = 1 - dist / radius;
const falloff = proximity * proximity;
p.vx += (-dy / dist) * falloff * this.config.mouseSwirl
+ (-dx / dist) * falloff * this.config.mousePull;
p.vy += (dx / dist) * falloff * this.config.mouseSwirl
+ (-dy / dist) * falloff * this.config.mousePull;
}
}
// Smooth the glow so the highlight eases in and out.
p.glow += (proximity - p.glow) * 0.12;
const speedSq = p.vx * p.vx + p.vy * p.vy;
if (speedSq > maxSpeed * maxSpeed) {
const scale = maxSpeed / Math.sqrt(speedSq);
p.vx *= scale;
p.vy *= scale;
}
p.px = p.x;
p.py = p.y;
p.x += p.vx;
p.y += p.vy;
// Wrap at the edges; mark fresh so no cross-screen streak is drawn.
if (p.x < 0 || p.x > this.width || p.y < 0 || p.y > this.height) {
p.x = (p.x + this.width) % this.width;
p.y = (p.y + this.height) % this.height;
p.fresh = true;
}
if (++p.life > p.maxLife) {
this.respawn(p);
}
}
}
updateSparks() {
const drag = 0.91;
const { fieldScale } = this.config;
const t = this.time;
for (let i = this.sparks.length - 1; i >= 0; i--) {
const s = this.sparks[i];
s.px = s.x;
s.py = s.y;
// Gently blend spark velocity with noise field as it slows down
const angle = this.noise.noise2D(s.x * fieldScale + t, s.y * fieldScale - t) * Math.PI * 2;
s.vx = s.vx * drag + Math.cos(angle) * 0.15;
s.vy = s.vy * drag + Math.sin(angle) * 0.15;
s.x += s.vx;
s.y += s.vy;
s.life++;
if (s.life >= s.maxLife) {
this.sparks.splice(i, 1);
}
}
}
fade() {
// Erase a fraction of the previous frame towards transparency so
// the CSS backdrop behind the canvas keeps showing through.
this.ctx.globalCompositeOperation = 'destination-out';
this.ctx.fillStyle = `rgba(0, 0, 0, ${this.config.fadeAlpha})`;
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;
const particles = this.particles;
const n = particles.length;
const { alphaStep, alphaLevels, widthStep, widthLevels, lineKeys, dotKeys } = this;
const glowBoost = this.config.glowBoost;
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 * glowBoost);
const a = Math.round(alpha / alphaStep);
if (a === 0) continue;
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;
}
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();
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;
}
}
drawSparks() {
if (!this.sparks.length) return;
const ctx = this.ctx;
ctx.globalCompositeOperation = 'lighter';
for (const s of this.sparks) {
const lifeRatio = 1 - s.life / s.maxLife;
const alpha = (s.alpha * lifeRatio * lifeRatio).toFixed(3);
if (alpha <= 0) continue;
ctx.strokeStyle = `rgba(${s.rgb}, ${alpha})`;
ctx.lineWidth = s.size * lifeRatio;
ctx.beginPath();
ctx.moveTo(s.px, s.py);
ctx.lineTo(s.x, s.y);
ctx.stroke();
}
}
step(withFade) {
if (withFade) this.fade();
this.updateSparks();
this.updateParticles();
this.drawParticles();
this.drawSparks();
this.time += this.config.timeDrift;
}
warmup(steps) {
for (let i = 0; i < steps; i++) {
this.step(false);
}
}
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);
}
}
document.addEventListener('DOMContentLoaded', () => {
// 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();
});
+320
View File
@@ -0,0 +1,320 @@
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();
});