Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870afc8b88 | ||
|
|
67335fb55b | ||
|
|
e205172fce | ||
|
|
6a93e9ae8d | ||
|
|
376a9aa5e4 | ||
|
|
269b306b87 | ||
|
|
632b807437 | ||
|
|
b330959cc5 |
No files matched your search
@@ -11,7 +11,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Homepage Image
|
||||
runs-on: ubuntu-amd64
|
||||
runs-on: runner-slim-amd64
|
||||
outputs:
|
||||
short_sha: ${{ steps.vars.outputs.short_sha }}
|
||||
steps:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: Deploy Homepage
|
||||
runs-on: ubuntu-amd64
|
||||
runs-on: runner-slim-amd64
|
||||
needs: build
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
||||
@@ -15,7 +15,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Homepage Image
|
||||
runs-on: ubuntu-amd64
|
||||
runs-on: runner-slim-amd64
|
||||
outputs:
|
||||
short_sha: ${{ steps.vars.outputs.short_sha }}
|
||||
steps:
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: Deploy Homepage
|
||||
runs-on: ubuntu-amd64
|
||||
runs-on: runner-slim-amd64
|
||||
needs: build
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
||||
@@ -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/matrix.js"></script>
|
||||
<script src="js/flowfield.js?v=20261012-1"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
+6
-11
@@ -4,21 +4,16 @@ WORKDIR /src
|
||||
|
||||
COPY . /src
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
FROM nginx:alpine-slim AS final
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
COPY . /usr/share/nginx/html
|
||||
COPY --from=build /src/js /usr/share/nginx/html/js
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -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:~$ _
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
+23
-32
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+10
-10
@@ -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/matrix.js"></script>
|
||||
<script src="js/flowfield.js?v=20261012-1"></script>
|
||||
<script src="js/social.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+655
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
Reference in new issue
Block a user