Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b85a4c6b |
No files matched your search
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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="description" content="The requested page could not be found.">
|
||||||
<meta name="author" content="ivanch">
|
<meta name="author" content="ivanch">
|
||||||
|
|
||||||
@@ -15,13 +15,13 @@
|
|||||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||||
|
|
||||||
<title>404 | ivanch</title>
|
<title>404 | ivanch</title>
|
||||||
<link rel="stylesheet" href="css/main.css?v=20261012-1">
|
<link rel="stylesheet" href="css/main.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">
|
<link rel="stylesheet" href="css/button.css">
|
||||||
<link rel="stylesheet" href="css/sections/error.css">
|
<link rel="stylesheet" href="css/sections/error.css">
|
||||||
|
|
||||||
<!-- Remix Icon subset (only the icon used on this page) -->
|
<!-- Remix Icon CDN for the navigation icon -->
|
||||||
<link rel="stylesheet" href="css/icons.css">
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="js/flowfield.js?v=20261012-1"></script>
|
<script src="js/flowfield.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -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.
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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
@@ -16,6 +16,7 @@ body {
|
|||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
background: var(--background-color);
|
background: var(--background-color);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -59,19 +60,18 @@ img {
|
|||||||
border-top: 1px solid var(--glass-border);
|
border-top: 1px solid var(--glass-border);
|
||||||
border-bottom: 1px solid var(--glass-border);
|
border-bottom: 1px solid var(--glass-border);
|
||||||
box-shadow: var(--shadow-lg), var(--inset-shadow);
|
box-shadow: var(--shadow-lg), var(--inset-shadow);
|
||||||
|
will-change: box-shadow;
|
||||||
animation: glass-shadow 10s ease-in-out infinite;
|
animation: glass-shadow 10s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
--page-gutter: var(--space-lg);
|
position: absolute;
|
||||||
--page-top: var(--space-xl);
|
top: 50%;
|
||||||
position: relative;
|
left: 50%;
|
||||||
margin: 0 auto;
|
transform: translate(-50%, -50%);
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
padding: 0 var(--space-lg);
|
||||||
min-height: 100svh;
|
|
||||||
padding: var(--page-top) var(--page-gutter) var(--space-xl);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -114,8 +114,8 @@ img {
|
|||||||
padding: var(--space-2xl) var(--space-xl);
|
padding: var(--space-2xl) var(--space-xl);
|
||||||
transition: background var(--dur-med) var(--ease-out-soft),
|
transition: background var(--dur-med) var(--ease-out-soft),
|
||||||
box-shadow var(--dur-med) var(--ease-out-soft);
|
box-shadow var(--dur-med) var(--ease-out-soft);
|
||||||
min-width: 0;
|
min-width: 26rem;
|
||||||
width: 100%;
|
max-width: 26rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -158,36 +158,45 @@ img {
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Mobile responsive layout */
|
/* 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) {
|
@media (max-width: 1024px) {
|
||||||
.main-container {
|
.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;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content,
|
||||||
|
.homelab-section,
|
||||||
|
.projects-section {
|
||||||
|
min-width: unset;
|
||||||
|
max-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: var(--space-xl) var(--space-lg);
|
padding: var(--space-xl) var(--space-lg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
--page-gutter: var(--space-xs);
|
padding: var(--space-xl) var(--space-sm);
|
||||||
--page-top: var(--space-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: var(--space-lg) var(--space-md);
|
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
@@ -8,40 +8,14 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(0, 0, 0, 0.88);
|
background: rgba(0, 0, 0, 0.88);
|
||||||
/* Colors are pre-dimmed (x0.7) rather than using `filter: brightness()`,
|
|
||||||
which would re-filter the whole animated canvas every frame. The
|
|
||||||
particle colors get the same treatment in js/flowfield.js. */
|
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(120% 100% at 50% 0%, rgba(14, 17, 34, 0.55) 0%, rgba(0, 0, 0, 0) 60%),
|
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(13, 13, 27, 0.72) 100%);
|
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. */
|
@media (prefers-reduced-motion: reduce) {
|
||||||
/* Keep this query in sync with js/flowfield.js. */
|
.matrix-canvas {
|
||||||
@media (max-width: 1024px), (pointer: coarse), (prefers-reduced-motion: reduce) {
|
filter: brightness(0.6) saturate(0.9);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+23
-21
@@ -5,18 +5,17 @@
|
|||||||
padding: var(--space-2xl);
|
padding: var(--space-2xl);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px minmax(0, 1fr);
|
grid-template-columns: 200px 1fr;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
animation: fadeInUp 0.8s var(--ease-out-expo);
|
animation: fadeInUp 0.8s var(--ease-out-expo);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-details {
|
.hero-details {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
flex-direction: row;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -154,6 +153,7 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.hero-details {
|
.hero-details {
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +166,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-tags {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -180,13 +176,8 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-details {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-links {
|
.social-links {
|
||||||
@@ -200,24 +191,35 @@
|
|||||||
padding: var(--space-lg) var(--space-md);
|
padding: var(--space-lg) var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.social-links {
|
.social-links {
|
||||||
display: grid;
|
gap: var(--space-xs);
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-link {
|
.social-link {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
max-width: calc(50% - 6px);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-link:last-child {
|
.social-link span {
|
||||||
grid-column: 1 / -1;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-link i {
|
.social-link i {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.social-link {
|
||||||
|
max-width: calc(100% / 2 - 6px);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
.homelab-section {
|
.homelab-section {
|
||||||
|
margin-top: var(--space-md);
|
||||||
padding: var(--space-xl) var(--space-lg);
|
padding: var(--space-xl) var(--space-lg);
|
||||||
background: rgba(20, 20, 30, 0.10);
|
background: rgba(20, 20, 30, 0.10);
|
||||||
backdrop-filter: blur(48px);
|
backdrop-filter: blur(48px);
|
||||||
@@ -6,8 +7,8 @@
|
|||||||
border-radius: var(--border-radius-large);
|
border-radius: var(--border-radius-large);
|
||||||
box-shadow: var(--shadow-md), var(--inset-shadow);
|
box-shadow: var(--shadow-md), var(--inset-shadow);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
min-width: 0;
|
min-width: 26rem;
|
||||||
width: 100%;
|
max-width: 26rem;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -219,11 +220,12 @@
|
|||||||
.homelab-buttons {
|
.homelab-buttons {
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.homelab-btn {
|
.homelab-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,26 +262,6 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Responsive */
|
/* 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) {
|
@media (max-width: 1024px) {
|
||||||
.profile-info {
|
.profile-info {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -324,7 +304,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-info-item {
|
.profile-info-item {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
@@ -347,6 +327,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-info-item {
|
.profile-info-item {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-17
@@ -1,4 +1,5 @@
|
|||||||
.projects-section {
|
.projects-section {
|
||||||
|
flex: 0 0 340px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
@@ -9,8 +10,8 @@
|
|||||||
border-radius: var(--border-radius-large);
|
border-radius: var(--border-radius-large);
|
||||||
box-shadow: var(--shadow-md), var(--inset-shadow);
|
box-shadow: var(--shadow-md), var(--inset-shadow);
|
||||||
border-bottom: 1px solid var(--glass-border);
|
border-bottom: 1px solid var(--glass-border);
|
||||||
min-width: 0;
|
min-width: 380px;
|
||||||
width: 100%;
|
max-width: 440px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +112,13 @@
|
|||||||
flex-wrap: nowrap;
|
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 {
|
.project-title > span:first-child {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-icon-link {
|
.project-icon-link {
|
||||||
@@ -147,16 +150,12 @@
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.project-icon-link {
|
.projects-section {
|
||||||
width: 44px;
|
flex: unset;
|
||||||
height: 44px;
|
max-width: 100%;
|
||||||
}
|
margin-left: 0;
|
||||||
}
|
margin-top: var(--space-xl);
|
||||||
|
min-width: unset;
|
||||||
@media (pointer: coarse) {
|
|
||||||
.project-icon-link {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +179,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.projects-title {
|
.projects-title {
|
||||||
font-size: var(--text-md);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
@@ -192,7 +191,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-desc {
|
.project-desc {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-icons {
|
.project-icons {
|
||||||
@@ -206,7 +205,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-title {
|
.project-title {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|||||||
+11
-11
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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="description" content="ivanch's personal portfolio showcasing projects and homelab setup.">
|
||||||
<meta name="author" content="ivanch">
|
<meta name="author" content="ivanch">
|
||||||
|
|
||||||
@@ -14,18 +14,18 @@
|
|||||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||||
|
|
||||||
<title>ivanch</title>
|
<title>ivanch</title>
|
||||||
<link rel="stylesheet" href="css/main.css?v=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/profile.css">
|
||||||
<link rel="stylesheet" href="css/sections/homelab.css?v=20261011-2">
|
<link rel="stylesheet" href="css/sections/homelab.css">
|
||||||
<link rel="stylesheet" href="css/sections/projects.css?v=20261011-2">
|
<link rel="stylesheet" href="css/sections/projects.css">
|
||||||
<link rel="stylesheet" href="css/sections/hero.css?v=20261011-2">
|
<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">
|
<link rel="stylesheet" href="css/button.css">
|
||||||
|
|
||||||
<!-- Remix Icon subset (only the icons used on this page) -->
|
<!-- Remix Icon CDN for social icons -->
|
||||||
<link rel="stylesheet" href="css/icons.css">
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas class="matrix-canvas" id="matrixCanvas"></canvas>
|
<canvas class="matrix-canvas" id="matrixCanvas"></canvas>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="glass hero-card">
|
<div class="glass hero-card">
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<img src="/assets/lain.jpg" alt="Avatar" width="320" height="320" decoding="async" />
|
<img src="/assets/lain.png" alt="Avatar" />
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<div class="profile-info-item">
|
<div class="profile-info-item">
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="js/flowfield.js?v=20261012-1"></script>
|
<script src="js/flowfield.js"></script>
|
||||||
<script src="js/social.js"></script>
|
<script src="js/social.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+41
-188
@@ -10,12 +10,6 @@
|
|||||||
* Trails are drawn by erasing the previous frame with a low-alpha
|
* Trails are drawn by erasing the previous frame with a low-alpha
|
||||||
* `destination-out` pass, so the CSS gradient painted behind the canvas
|
* `destination-out` pass, so the CSS gradient painted behind the canvas
|
||||||
* element keeps showing through instead of being flooded by an opaque fill.
|
* element keeps showing through instead of being flooded by an opaque fill.
|
||||||
*
|
|
||||||
* Performance notes: particles are bucketed by (tone, width, alpha) and each
|
|
||||||
* bucket is stroked as a single path, so a frame issues roughly a third of
|
|
||||||
* the draw calls of one-per-particle, with no per-frame string allocation.
|
|
||||||
* The loop is capped at ~60 fps so high-refresh displays don't do 2x the
|
|
||||||
* work (the simulation is frame-based, so this also keeps speed consistent).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Deterministic PRNG so the noise permutation is stable within a page load. */
|
/* Deterministic PRNG so the noise permutation is stable within a page load. */
|
||||||
@@ -122,10 +116,7 @@ class FlowFieldBackground {
|
|||||||
this.sparks = [];
|
this.sparks = [];
|
||||||
this.mouse = { x: 0, y: 0, active: false };
|
this.mouse = { x: 0, y: 0, active: false };
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
this.resizeRaf = null;
|
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
this.lastFrame = 0;
|
|
||||||
this.frameInterval = 1000 / 60;
|
|
||||||
this.events = new AbortController();
|
|
||||||
|
|
||||||
this.width = 0;
|
this.width = 0;
|
||||||
this.height = 0;
|
this.height = 0;
|
||||||
@@ -159,84 +150,54 @@ class FlowFieldBackground {
|
|||||||
{ weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet
|
{ weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet
|
||||||
];
|
];
|
||||||
|
|
||||||
this.buildStyleCache();
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
buildStyleCache() {
|
|
||||||
// Colors are dimmed here instead of with a CSS `filter: brightness()`
|
|
||||||
// on the canvas, which would cost a full-screen filter pass per frame.
|
|
||||||
// Additive blending is linear, so the result is nearly identical.
|
|
||||||
const dim = 0.7;
|
|
||||||
const dimRgb = (rgb) => rgb.split(',').map((c) => Math.round(c * dim)).join(', ');
|
|
||||||
for (const tone of this.palette) tone.drawRgb = dimRgb(tone.rgb);
|
|
||||||
const dotRgb = dimRgb('240, 248, 255');
|
|
||||||
|
|
||||||
// Alpha and line width are quantized so particles can share a path.
|
|
||||||
// Steps are small enough that the difference is not visible.
|
|
||||||
this.alphaStep = 0.02;
|
|
||||||
this.alphaLevels = Math.round(1 / this.alphaStep) + 1;
|
|
||||||
this.widthStep = 0.2;
|
|
||||||
this.widthLevels = 10; // covers the 0.4px - 1.8px particle range
|
|
||||||
|
|
||||||
this.strokeStyles = [];
|
|
||||||
for (const tone of this.palette) {
|
|
||||||
for (let a = 0; a < this.alphaLevels; a++) {
|
|
||||||
this.strokeStyles.push(`rgba(${tone.drawRgb}, ${(a * this.alphaStep).toFixed(3)})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.dotStyles = [];
|
|
||||||
for (let a = 0; a < this.alphaLevels; a++) {
|
|
||||||
this.dotStyles.push(`rgba(${dotRgb}, ${(a * this.alphaStep).toFixed(3)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineBuckets = this.palette.length * this.widthLevels * this.alphaLevels;
|
|
||||||
this.lineCounts = new Uint16Array(lineBuckets + 1);
|
|
||||||
this.dotCounts = new Uint16Array(this.alphaLevels + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.handleResize();
|
this.handleResize();
|
||||||
|
|
||||||
|
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.
|
// Pre-run a short exposure so the first paint is already textured.
|
||||||
this.warmup(60);
|
this.warmup(60);
|
||||||
this.animate();
|
this.animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
const signal = this.events.signal;
|
|
||||||
// The canvas has pointer-events: none, so listen on window.
|
// The canvas has pointer-events: none, so listen on window.
|
||||||
window.addEventListener('pointermove', (e) => {
|
window.addEventListener('pointermove', (e) => {
|
||||||
this.mouse.x = e.clientX;
|
this.mouse.x = e.clientX;
|
||||||
this.mouse.y = e.clientY;
|
this.mouse.y = e.clientY;
|
||||||
this.mouse.active = true;
|
this.mouse.active = true;
|
||||||
}, { passive: true, signal });
|
}, { passive: true });
|
||||||
|
|
||||||
window.addEventListener('pointerdown', (e) => {
|
window.addEventListener('pointerdown', (e) => {
|
||||||
this.spawnClickBurst(e.clientX, e.clientY);
|
this.spawnClickBurst(e.clientX, e.clientY);
|
||||||
}, { passive: true, signal });
|
}, { passive: true });
|
||||||
|
|
||||||
window.addEventListener('pointerout', () => {
|
window.addEventListener('pointerout', () => {
|
||||||
this.mouse.active = false;
|
this.mouse.active = false;
|
||||||
}, { signal });
|
});
|
||||||
|
|
||||||
// Pause the rAF loop when the tab is hidden to save CPU / battery.
|
// Pause the rAF loop when the tab is hidden to save CPU / battery.
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
this.stop();
|
this.stop();
|
||||||
} else {
|
} else if (!this.prefersReducedMotion) {
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
}, { signal });
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
|
|
||||||
this.resizeRaf = requestAnimationFrame(() => {
|
|
||||||
this.resizeRaf = null;
|
|
||||||
this.handleResize();
|
|
||||||
});
|
});
|
||||||
}, { signal });
|
|
||||||
|
let resizeRaf = null;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (resizeRaf) cancelAnimationFrame(resizeRaf);
|
||||||
|
resizeRaf = requestAnimationFrame(() => this.handleResize());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -251,17 +212,6 @@ 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() {
|
handleResize() {
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
const h = window.innerHeight;
|
const h = window.innerHeight;
|
||||||
@@ -272,9 +222,7 @@ class FlowFieldBackground {
|
|||||||
|
|
||||||
this.width = w;
|
this.width = w;
|
||||||
this.height = h;
|
this.height = h;
|
||||||
// The strokes are faint and soft, so a 1x buffer looks nearly the same
|
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
// on HiDPI screens while using a quarter of the memory and fill cost.
|
|
||||||
this.dpr = 1;
|
|
||||||
this.canvas.width = Math.round(w * this.dpr);
|
this.canvas.width = Math.round(w * this.dpr);
|
||||||
this.canvas.height = Math.round(h * this.dpr);
|
this.canvas.height = Math.round(h * this.dpr);
|
||||||
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
@@ -286,28 +234,21 @@ class FlowFieldBackground {
|
|||||||
this.particles.push(this.createParticle());
|
this.particles.push(this.createParticle());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scratch buffers for bucketing particles at draw time.
|
|
||||||
this.lineKeys = new Int16Array(count);
|
|
||||||
this.lineOrder = new Uint16Array(count);
|
|
||||||
this.dotKeys = new Int16Array(count);
|
|
||||||
this.dotOrder = new Uint16Array(count);
|
|
||||||
|
|
||||||
// Repaint a short exposure so a resize never flashes an empty canvas.
|
// Repaint a short exposure so a resize never flashes an empty canvas.
|
||||||
this.warmup(30);
|
this.warmup(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
pickTone() {
|
pickTone() {
|
||||||
let roll = Math.random();
|
let roll = Math.random();
|
||||||
for (let i = 0; i < this.palette.length; i++) {
|
for (const tone of this.palette) {
|
||||||
roll -= this.palette[i].weight;
|
roll -= tone.weight;
|
||||||
if (roll <= 0) return i;
|
if (roll <= 0) return tone;
|
||||||
}
|
}
|
||||||
return 0;
|
return this.palette[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
createParticle() {
|
createParticle() {
|
||||||
const toneIndex = this.pickTone();
|
const tone = this.pickTone();
|
||||||
const tone = this.palette[toneIndex];
|
|
||||||
const { min, max } = this.config.life;
|
const { min, max } = this.config.life;
|
||||||
|
|
||||||
// 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight)
|
// 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight)
|
||||||
@@ -339,7 +280,7 @@ class FlowFieldBackground {
|
|||||||
vy: 0,
|
vy: 0,
|
||||||
speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5),
|
speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5),
|
||||||
width: (0.7 + Math.random() * 0.5) * widthMult,
|
width: (0.7 + Math.random() * 0.5) * widthMult,
|
||||||
tone: toneIndex,
|
rgb: tone.rgb,
|
||||||
baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult,
|
baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult,
|
||||||
alpha: 0,
|
alpha: 0,
|
||||||
glow: 0,
|
glow: 0,
|
||||||
@@ -364,7 +305,7 @@ class FlowFieldBackground {
|
|||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const angle = Math.random() * Math.PI * 2;
|
||||||
const speed = 1.0 + Math.random() * 2.2;
|
const speed = 1.0 + Math.random() * 2.2;
|
||||||
const tone = this.palette[this.pickTone()];
|
const tone = this.pickTone();
|
||||||
this.sparks.push({
|
this.sparks.push({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -372,7 +313,7 @@ class FlowFieldBackground {
|
|||||||
py: y,
|
py: y,
|
||||||
vx: Math.cos(angle) * speed,
|
vx: Math.cos(angle) * speed,
|
||||||
vy: Math.sin(angle) * speed,
|
vy: Math.sin(angle) * speed,
|
||||||
rgb: tone.drawRgb,
|
rgb: tone.rgb,
|
||||||
size: 0.6 + Math.random() * 0.8,
|
size: 0.6 + Math.random() * 0.8,
|
||||||
alpha: 0.6 + Math.random() * 0.25,
|
alpha: 0.6 + Math.random() * 0.25,
|
||||||
life: 0,
|
life: 0,
|
||||||
@@ -491,99 +432,34 @@ class FlowFieldBackground {
|
|||||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Counting sort: groups particle indices in `order` by their bucket key
|
|
||||||
(keys < 0 are skipped). Afterwards counts[k] is the end offset of
|
|
||||||
bucket k, and bucket k starts where bucket k - 1 ends. */
|
|
||||||
sortByKey(keys, n, counts, order) {
|
|
||||||
counts.fill(0);
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
if (keys[i] >= 0) counts[keys[i] + 1]++;
|
|
||||||
}
|
|
||||||
for (let k = 1; k < counts.length; k++) counts[k] += counts[k - 1];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const k = keys[i];
|
|
||||||
if (k >= 0) order[counts[k]++] = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawParticles() {
|
drawParticles() {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const particles = this.particles;
|
// Additive blending gives a soft glow where currents overlap.
|
||||||
const n = particles.length;
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
const { alphaStep, alphaLevels, widthStep, widthLevels, lineKeys, dotKeys } = this;
|
ctx.lineCap = 'round';
|
||||||
const glowBoost = this.config.glowBoost;
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (const p of this.particles) {
|
||||||
const p = particles[i];
|
|
||||||
lineKeys[i] = -1;
|
|
||||||
dotKeys[i] = -1;
|
|
||||||
if (p.fresh) {
|
if (p.fresh) {
|
||||||
p.fresh = false;
|
p.fresh = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const alpha = Math.min(1, p.alpha + p.glow * glowBoost);
|
const alpha = Math.min(1, p.alpha + p.glow * this.config.glowBoost);
|
||||||
const a = Math.round(alpha / alphaStep);
|
if (alpha <= 0.005) continue;
|
||||||
if (a === 0) continue;
|
|
||||||
|
|
||||||
const w = Math.min(widthLevels - 1, Math.round(p.width / widthStep));
|
ctx.strokeStyle = `rgba(${p.rgb}, ${alpha.toFixed(3)})`;
|
||||||
lineKeys[i] = (p.tone * widthLevels + w) * alphaLevels + a;
|
ctx.lineWidth = p.width;
|
||||||
|
|
||||||
// A subtle bright dot at the head of foreground particles
|
|
||||||
if (p.depth === 2 && (p.glow > 0.1 || Math.random() < 0.12)) {
|
|
||||||
dotKeys[i] = Math.round((alpha * 0.75) / alphaStep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additive blending gives a soft glow where currents overlap. It is
|
|
||||||
// also order-independent, so batching does not change the result.
|
|
||||||
ctx.globalCompositeOperation = 'lighter';
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
|
|
||||||
const lineCounts = this.lineCounts;
|
|
||||||
const lineOrder = this.lineOrder;
|
|
||||||
this.sortByKey(lineKeys, n, lineCounts, lineOrder);
|
|
||||||
let start = 0;
|
|
||||||
for (let k = 0; k < lineCounts.length - 1; k++) {
|
|
||||||
const end = lineCounts[k];
|
|
||||||
if (end === start) continue;
|
|
||||||
|
|
||||||
const a = k % alphaLevels;
|
|
||||||
const toneWidth = (k - a) / alphaLevels;
|
|
||||||
const w = toneWidth % widthLevels;
|
|
||||||
const tone = (toneWidth - w) / widthLevels;
|
|
||||||
|
|
||||||
ctx.strokeStyle = this.strokeStyles[tone * alphaLevels + a];
|
|
||||||
ctx.lineWidth = w * widthStep;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let j = start; j < end; j++) {
|
|
||||||
const p = particles[lineOrder[j]];
|
|
||||||
ctx.moveTo(p.px, p.py);
|
ctx.moveTo(p.px, p.py);
|
||||||
ctx.lineTo(p.x, p.y);
|
ctx.lineTo(p.x, p.y);
|
||||||
}
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
start = end;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dotCounts = this.dotCounts;
|
// Draw a subtle bright dot at the head of foreground particles
|
||||||
const dotOrder = this.dotOrder;
|
if (p.depth === 2 && (p.glow > 0.1 || Math.random() < 0.12)) {
|
||||||
this.sortByKey(dotKeys, n, dotCounts, dotOrder);
|
ctx.fillStyle = `rgba(240, 248, 255, ${(alpha * 0.75).toFixed(3)})`;
|
||||||
start = 0;
|
|
||||||
for (let a = 0; a < dotCounts.length - 1; a++) {
|
|
||||||
const end = dotCounts[a];
|
|
||||||
if (end === start) continue;
|
|
||||||
|
|
||||||
if (a > 0) {
|
|
||||||
ctx.fillStyle = this.dotStyles[a];
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let j = start; j < end; j++) {
|
ctx.arc(p.x, p.y, p.width * 0.75, 0, Math.PI * 2);
|
||||||
const p = particles[dotOrder[j]];
|
|
||||||
const r = p.width * 0.75;
|
|
||||||
ctx.moveTo(p.x + r, p.y);
|
|
||||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
|
||||||
}
|
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
start = end;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,35 +497,12 @@ class FlowFieldBackground {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
animate(now = performance.now()) {
|
animate() {
|
||||||
this.rafId = requestAnimationFrame((t) => this.animate(t));
|
|
||||||
|
|
||||||
// Cap at ~60 fps; keep the remainder so 120/144 Hz average out to 60.
|
|
||||||
const elapsed = now - this.lastFrame;
|
|
||||||
if (elapsed < this.frameInterval * 0.9) return;
|
|
||||||
this.lastFrame = now - (elapsed % this.frameInterval);
|
|
||||||
|
|
||||||
this.step(true);
|
this.step(true);
|
||||||
|
this.rafId = requestAnimationFrame(() => this.animate());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Keep this query in sync with the static backdrop in css/matrix.css.
|
new FlowFieldBackground();
|
||||||
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
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user