Add Three.js page graphics and GSAP site-wide motion system

- Home hero: full-height, with an interactive 3D wireframe of my face
  (MediaPipe landmarks from my-photo.png) rendered as a network graph
  with packets, scan sweeps and an assemble-on-load entry animation
- Inner pages: shared PageHero with a themed scene per page
  (About helix, Skills geodesic sphere, Work infra stack, Blog signal
  waves, Contact globe with connection arcs)
- Shared Three.js stage (framing, GSAP entry, pointer, off-screen
  pausing, reduced motion) with lazily loaded per-scene chunks
- GSAP motion system: data-reveal / data-reveal-stagger / data-parallax
  attributes, SplitText headings, divider draw-in, page exit fade,
  no-JS and reduced-motion fallbacks
- Mobile: graphic stacks above the text

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-03 11:57:05 +01:00
co-authored by Claude Opus 5.5
parent 8c280be7b8
commit d93550ed29
34 changed files with 2562 additions and 143 deletions
+21
View File
@@ -8,6 +8,7 @@
"@moduletrace/bun-mariadb": "^1.1.1", "@moduletrace/bun-mariadb": "^1.1.1",
"@moduletrace/bunext": "^1.2.4", "@moduletrace/bunext": "^1.2.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gsap": "^3.15.0",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
@@ -22,6 +23,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"three": "^0.187.0",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
@@ -32,6 +34,7 @@
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/showdown": "^2.0.6", "@types/showdown": "^2.0.6",
"@types/three": "^0.187.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^5", "typescript": "^5",
@@ -45,6 +48,8 @@
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@dimforge/rapier3d-compat": ["@dimforge/[email protected]", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], "@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
@@ -209,6 +214,8 @@
"@tailwindcss/postcss": ["@tailwindcss/[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], "@tailwindcss/postcss": ["@tailwindcss/[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
"@tweenjs/tween.js": ["@tweenjs/[email protected]", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
@@ -241,8 +248,14 @@
"@types/showdown": ["@types/[email protected]", "", {}, "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="], "@types/showdown": ["@types/[email protected]", "", {}, "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="],
"@types/stats.js": ["@types/[email protected]", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/[email protected]", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-v+vKNarUtk4n2QRZ4+CN5ygMM0ARUjXjIQl0R/unMUfmStFeVqGbgqarbrpoGTXRZoulSH16ePrVJVxPJEbMUA=="],
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/webxr": ["@types/[email protected]", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], "@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@@ -367,6 +380,8 @@
"fast-wrap-ansi": ["[email protected]", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fast-wrap-ansi": ["[email protected]", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="], "find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -379,6 +394,8 @@
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"gsap": ["[email protected]", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-from-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
@@ -533,6 +550,8 @@
"meow": ["[email protected]", "", { "dependencies": { "camelcase-keys": "^2.0.0", "loud-rejection": "^1.0.0", "minimist": "^1.1.3", "normalize-package-data": "^2.3.4", "object-assign": "^4.0.1", "read-pkg-up": "^1.0.1", "redent": "^1.0.0", "trim-newlines": "^1.0.0" } }, "sha512-1zRGO8C/2QD8uBxZbwwKbIQHrHKANzVnlK/3Gj7xro+ks4HLmayvETy+BnCV+wm68PE6dYcfgyTDMVG2mjlQwg=="], "meow": ["[email protected]", "", { "dependencies": { "camelcase-keys": "^2.0.0", "loud-rejection": "^1.0.0", "minimist": "^1.1.3", "normalize-package-data": "^2.3.4", "object-assign": "^4.0.1", "read-pkg-up": "^1.0.1", "redent": "^1.0.0", "trim-newlines": "^1.0.0" } }, "sha512-1zRGO8C/2QD8uBxZbwwKbIQHrHKANzVnlK/3Gj7xro+ks4HLmayvETy+BnCV+wm68PE6dYcfgyTDMVG2mjlQwg=="],
"meshoptimizer": ["[email protected]", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], "micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -763,6 +782,8 @@
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"three": ["[email protected]", "", {}, "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA=="],
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trim-newlines": ["[email protected]", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="], "trim-newlines": ["[email protected]", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="],
+4 -1
View File
@@ -14,6 +14,7 @@
"@moduletrace/bun-mariadb": "^1.1.1", "@moduletrace/bun-mariadb": "^1.1.1",
"@moduletrace/bunext": "^1.2.4", "@moduletrace/bunext": "^1.2.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"gsap": "^3.15.0",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
@@ -27,7 +28,8 @@
"rehype-prism-plus": "^2.0.1", "rehype-prism-plus": "^2.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"tailwind-merge": "^2.5.5" "tailwind-merge": "^2.5.5",
"three": "^0.187.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
@@ -38,6 +40,7 @@
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/showdown": "^2.0.6", "@types/showdown": "^2.0.6",
"@types/three": "^0.187.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^5" "typescript": "^5"
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import H1 from "@/src/components/lib/layout/H1";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import SceneCanvas, { SceneName } from "@/src/components/lib/three/SceneCanvas";
type Props = {
title: React.ReactNode;
subtitle?: React.ReactNode;
scene: SceneName;
/** Small label above the title. */
eyebrow?: React.ReactNode;
/** Extra content under the subtitle (CTAs, contact details...). */
children?: React.ReactNode;
className?: string;
};
/**
* Inner-page hero: animated title block on the left, the page's 3D graphic
* on the right (behind the text, dimmed, on smaller screens).
*/
export default function PageHero({
title,
subtitle,
scene,
eyebrow,
children,
className,
}: Props) {
return (
<Section
className={twMerge(
"relative overflow-hidden justify-start xl:justify-center",
"xl:min-h-[clamp(440px,64svh,760px)]",
"pt-4! xl:pt-20!",
className,
)}
>
<div
className={twMerge(
"relative w-full h-[38svh] min-h-[260px] max-h-[440px] mb-4",
"xl:absolute xl:inset-y-0 xl:right-0 xl:left-auto",
"xl:w-1/2 xl:h-auto xl:max-h-none xl:mb-0",
"xl:[mask-image:linear-gradient(to_right,transparent,black_18%)]",
)}
data-parallax="0.4"
>
<SceneCanvas scene={scene} className="w-full h-full" />
</div>
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw] gap-6">
{eyebrow && (
<Span
className="text-sm uppercase tracking-[0.2em] dark:text-emerald-400/80"
data-reveal="fade"
>
{eyebrow}
</Span>
)}
<H1 className="leading-snug m-0!" data-reveal="text">
{title}
</H1>
{subtitle && (
<Span
className="text-xl"
data-reveal="up"
data-reveal-delay="0.35"
>
{subtitle}
</Span>
)}
{children && (
<div
className="w-full"
data-reveal="up"
data-reveal-delay="0.5"
>
{children}
</div>
)}
</Stack>
</Section>
);
}
+2 -1
View File
@@ -18,6 +18,7 @@ type Props = DetailedHTMLProps<
export default function Divider({ vertical, dashed, ...props }: Props) { export default function Divider({ vertical, dashed, ...props }: Props) {
return ( return (
<div <div
data-reveal={vertical ? undefined : "line"}
{...props} {...props}
className={twMerge( className={twMerge(
"border-slate-200 dark:border-white/10", "border-slate-200 dark:border-white/10",
@@ -27,7 +28,7 @@ export default function Divider({ vertical, dashed, ...props }: Props) {
"twui-divider", "twui-divider",
vertical ? "twui-divider-vertical" : "twui-divider-horizontal", vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
dashed ? "border-dashed" : "border-solid", dashed ? "border-dashed" : "border-solid",
props.className props.className,
)} )}
/> />
); );
+11
View File
@@ -0,0 +1,11 @@
/**
* Inline <head> script: runs before first paint so motion elements can be
* pre-hidden without a flash. Skipped for reduced-motion users. If the motion
* system hasn't booted within 3s (JS error, slow network), everything is
* revealed so content is never stuck invisible.
*/
export const MotionHeadScript = `(function(){try{
if(window.matchMedia("(prefers-reduced-motion: reduce)").matches)return;
var d=document.documentElement;d.classList.add("motion");
setTimeout(function(){if(!window.__MOTION_READY)d.classList.add("motion-fallback");},3000);
}catch(e){}})();`;
+279
View File
@@ -0,0 +1,279 @@
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
/**
* # Site-wide motion system
*
* Declarative, attribute-driven animations powered by GSAP. Markup opts in:
*
* - `data-reveal="up|down|fade|left|right|scale|blur|text|line"`
* Animate the element in when it scrolls into view (default `up`).
* `text` splits the element into masked lines/words that rise in.
* `line` draws the element in horizontally (dividers, rules).
* - `data-reveal-delay="0.2"` extra delay in seconds.
* - `data-reveal-stagger` reveal each direct child in sequence
* (optional value = variant for children).
* - `data-parallax="0.3"` scrubbed drift + fade as the element scrolls
* out of view (value = strength).
*
* Elements are pre-hidden by CSS only once `html.motion` is set by the inline
* head script, so no-JS / reduced-motion visitors always see content.
*/
type Variant =
| "up"
| "down"
| "fade"
| "left"
| "right"
| "scale"
| "blur"
| "text"
| "line";
const FROM: Record<Exclude<Variant, "text">, gsap.TweenVars> = {
up: { y: 28 },
down: { y: -20 },
fade: {},
left: { x: -36 },
right: { x: 36 },
scale: { scale: 0.94 },
blur: { filter: "blur(12px)", y: 12 },
line: { scaleX: 0, transformOrigin: "left center" },
};
const DONE_ATTR = "data-reveal-done";
const EASE = "power3.out";
declare global {
interface Window {
__MOTION_READY?: boolean;
}
}
let registered = false;
let observer: MutationObserver | undefined;
let refreshTimer: number | undefined;
let refCount = 0;
const cleanups: (() => void)[] = [];
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function parseVariant(v: string | null, fallback: Variant = "up"): Variant {
return v && (v in FROM || v === "text") ? (v as Variant) : fallback;
}
function variantOf(el: Element, fallback: Variant = "up"): Variant {
return parseVariant(el.getAttribute("data-reveal"), fallback);
}
function delayOf(el: Element) {
return parseFloat(el.getAttribute("data-reveal-delay") || "0") || 0;
}
function trigger(el: Element) {
return {
trigger: el,
start: "top 90%",
once: true,
} satisfies ScrollTrigger.Vars;
}
function revealText(el: HTMLElement, delay: number) {
const split = SplitText.create(el, {
type: "lines,words",
mask: "lines",
linesClass: "motion-line",
autoSplit: true,
onSplit(self) {
gsap.set(el, { autoAlpha: 1 });
return gsap.from(self.words, {
yPercent: 110,
duration: 1.1,
ease: "expo.out",
stagger: 0.045,
delay,
scrollTrigger: trigger(el),
});
},
});
cleanups.push(() => split.revert());
}
function revealOne(el: HTMLElement, variant: Variant, delay: number) {
if (variant === "text") return revealText(el, delay);
gsap.fromTo(
el,
{ autoAlpha: 0, ...FROM[variant] },
{
autoAlpha: 1,
x: 0,
y: 0,
scale: variant === "line" ? undefined : 1,
scaleX: variant === "line" ? 1 : undefined,
filter: variant === "blur" ? "blur(0px)" : undefined,
duration: variant === "line" ? 1.4 : 1,
ease: variant === "line" ? "expo.inOut" : EASE,
delay,
clearProps: "transform,filter",
scrollTrigger: trigger(el),
},
);
}
function revealGroup(parent: HTMLElement) {
const childVariant = parseVariant(
parent.getAttribute("data-reveal-stagger"),
);
const children = Array.from(parent.children).filter(
(c): c is HTMLElement =>
c instanceof HTMLElement && !c.hasAttribute(DONE_ATTR),
);
if (!children.length) return;
children.forEach((c) => c.setAttribute(DONE_ATTR, ""));
gsap.set(parent, { autoAlpha: 1 });
const from = childVariant === "text" ? FROM.up : FROM[childVariant];
gsap.fromTo(
children,
{ autoAlpha: 0, ...from },
{
autoAlpha: 1,
x: 0,
y: 0,
scale: 1,
filter: childVariant === "blur" ? "blur(0px)" : undefined,
duration: 0.9,
ease: EASE,
stagger: 0.08,
delay: delayOf(parent),
clearProps: "transform,filter",
scrollTrigger: trigger(parent),
},
);
}
function parallax(el: HTMLElement) {
const strength = parseFloat(el.getAttribute("data-parallax") || "0.3");
const section = el.closest("section") ?? el;
gsap.to(el, {
yPercent: -30 * strength,
autoAlpha: 0,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: "bottom top",
scrub: true,
},
});
}
/** Process every not-yet-animated motion element inside `root`. */
function scan(root: ParentNode) {
root.querySelectorAll<HTMLElement>("[data-reveal-stagger]").forEach(
revealGroup,
);
root.querySelectorAll<HTMLElement>(
`[data-reveal]:not([${DONE_ATTR}])`,
).forEach((el) => {
el.setAttribute(DONE_ATTR, "");
revealOne(el, variantOf(el), delayOf(el));
});
root.querySelectorAll<HTMLElement>(
`[data-parallax]:not([${DONE_ATTR}])`,
).forEach((el) => {
el.setAttribute(DONE_ATTR, "");
parallax(el);
});
}
/** Fade the page out before following internal links. */
function setupPageExit() {
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = (e.target as Element | null)?.closest?.("a");
if (!a || a.target === "_blank" || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin) return;
if (url.pathname === location.pathname) return; // hash / same page
if (/\.(pdf|png|jpe?g|webp|gif|svg|zip)$/i.test(url.pathname)) return;
e.preventDefault();
gsap.to("main", {
autoAlpha: 0,
y: -12,
duration: 0.28,
ease: "power2.in",
onComplete: () => location.assign(url.href),
});
};
// restore if the page comes back from the back/forward cache
const onShow = (e: PageTransitionEvent) => {
if (e.persisted) gsap.set("main", { clearProps: "all" });
};
document.addEventListener("click", onClick);
window.addEventListener("pageshow", onShow);
cleanups.push(() => {
document.removeEventListener("click", onClick);
window.removeEventListener("pageshow", onShow);
});
}
export function initMotion() {
refCount++;
if (refCount > 1) return destroyMotion;
window.__MOTION_READY = true;
if (prefersReducedMotion()) {
document.documentElement.classList.remove("motion");
return destroyMotion;
}
if (!registered) {
gsap.registerPlugin(ScrollTrigger, SplitText);
registered = true;
}
scan(document);
setupPageExit();
// Pick up content React mounts later (tabs, lists, client fetches).
observer = new MutationObserver((mutations) => {
let found = false;
for (const m of mutations) {
m.addedNodes.forEach((n) => {
if (n instanceof HTMLElement) found = true;
});
}
if (!found) return;
scan(document);
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => ScrollTrigger.refresh(), 120);
});
observer.observe(document.body, { childList: true, subtree: true });
// Layout shifts after fonts/images load would misplace trigger points.
const onLoad = () => ScrollTrigger.refresh();
window.addEventListener("load", onLoad);
document.fonts?.ready.then(onLoad).catch(() => {});
cleanups.push(() => window.removeEventListener("load", onLoad));
return destroyMotion;
}
function destroyMotion() {
refCount = Math.max(0, refCount - 1);
if (refCount > 0) return;
observer?.disconnect();
observer = undefined;
window.clearTimeout(refreshTimer);
while (cleanups.length) cleanups.pop()!();
ScrollTrigger.getAll().forEach((t) => t.kill());
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
/** Boot the site-wide GSAP motion system (see ./motion.ts). */
export default function useMotion() {
React.useEffect(() => {
let destroy: (() => void) | undefined;
let cancelled = false;
import("./motion").then(({ initMotion }) => {
if (!cancelled) destroy = initMotion();
});
return () => {
cancelled = true;
destroy?.();
};
}, []);
}
+78
View File
@@ -0,0 +1,78 @@
import React from "react";
import { twMerge } from "tailwind-merge";
/**
* Each scene is its own lazily-loaded chunk; three.js + the stage are a
* shared chunk. Nothing 3D is downloaded until the component mounts.
*/
const SCENES = {
face: () => import("./scenes/face"),
globe: () => import("./scenes/globe"),
sphere: () => import("./scenes/sphere"),
stack: () => import("./scenes/stack"),
helix: () => import("./scenes/helix"),
waves: () => import("./scenes/waves"),
} as const;
export type SceneName = keyof typeof SCENES;
type Props = {
scene: SceneName;
className?: string;
};
export default function SceneCanvas({ scene, className }: Props) {
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
let disposed = false;
let dispose: (() => void) | undefined;
(async () => {
try {
const [{ createStage }, mod] = await Promise.all([
import("./stage"),
SCENES[scene](),
]);
if (disposed) return;
const handle = await createStage({
canvas,
container,
factory: mod.default,
onReady: () => setReady(true),
});
if (disposed) handle.dispose();
else dispose = handle.dispose;
} catch (error) {
// No WebGL / failed load: the page simply stays text-only.
console.warn(`SceneCanvas(${scene}) failed`, error);
}
})();
return () => {
disposed = true;
dispose?.();
};
}, [scene]);
return (
<div
ref={containerRef}
aria-hidden="true"
className={twMerge(
"pointer-events-none select-none",
"transition-opacity duration-700 ease-out",
ready ? "opacity-100" : "opacity-0",
className,
)}
>
<canvas ref={canvasRef} className="block w-full h-full" />
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import * as THREE from "three";
import type { Stage } from "./stage";
/**
* Shared visual language for every scene: additive glowing points and lines
* with per-vertex alpha and a depth fade (things further back are dimmer).
* Geometries must carry an `aAlpha` attribute; use the helpers below.
*/
const depthFade = /* glsl */ `
uniform float uDepthRange;
float depthFade(vec3 p) {
vec4 w = modelMatrix * vec4(p, 1.0);
return mix(0.18, 1.0, smoothstep(-uDepthRange, uDepthRange, w.z));
}
`;
export type GlowOptions = {
color: THREE.Color;
opacity?: number;
/** Points only: base size in px at the reference distance. */
size?: number;
/** World-space half-depth used for the depth fade. */
depthRange?: number;
};
export function glowPoints(stage: Stage, o: GlowOptions) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: o.color },
uOpacity: { value: o.opacity ?? 1 },
uSize: { value: o.size ?? 5 },
uPixelRatio: { value: stage.pixelRatio },
uDepthRange: { value: o.depthRange ?? 1.2 },
},
vertexShader: /* glsl */ `
attribute float aAlpha;
attribute float aSize;
uniform float uSize;
uniform float uPixelRatio;
varying float vAlpha;
${depthFade}
void main() {
vAlpha = aAlpha * depthFade(position);
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = min(uSize * aSize * (5.0 / -mv.z), 18.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - 0.5);
float a = pow(smoothstep(0.5, 0.0, r), 1.6);
if (a < 0.01) discard;
gl_FragColor = vec4(mix(uColor, vec3(1.0), a * 0.35), a * vAlpha * uOpacity);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
}
export function glowLines(o: GlowOptions) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: o.color },
uOpacity: { value: o.opacity ?? 1 },
uDepthRange: { value: o.depthRange ?? 1.2 },
},
vertexShader: /* glsl */ `
attribute float aAlpha;
varying float vAlpha;
${depthFade}
void main() {
vAlpha = aAlpha * depthFade(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vAlpha;
void main() {
gl_FragColor = vec4(uColor, vAlpha * uOpacity);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
}
/** Geometry with position + aAlpha (default 1) + aSize (default 1). */
export function glowGeometry(
positions: Float32Array | number[],
opts: { index?: number[]; dynamic?: boolean } = {},
) {
const pos =
positions instanceof Float32Array
? positions
: new Float32Array(positions);
const count = pos.length / 3;
const g = new THREE.BufferGeometry();
const position = new THREE.BufferAttribute(pos, 3);
const alpha = new THREE.BufferAttribute(new Float32Array(count).fill(1), 1);
const size = new THREE.BufferAttribute(new Float32Array(count).fill(1), 1);
if (opts.dynamic) {
position.setUsage(THREE.DynamicDrawUsage);
alpha.setUsage(THREE.DynamicDrawUsage);
}
g.setAttribute("position", position);
g.setAttribute("aAlpha", alpha);
g.setAttribute("aSize", size);
if (opts.index) g.setIndex(opts.index);
return g;
}
export const clamp01 = (x: number) => Math.min(Math.max(x, 0), 1);
export const smooth = (x: number) => {
const t = clamp01(x);
return t * t * (3 - 2 * t);
};
/** Staggered sub-progress: element `i` of `n` within an overall 0→1. */
export const stagger = (p: number, i: number, n: number, span = 0.5) =>
smooth((p - (i / Math.max(n - 1, 1)) * (1 - span)) / span);
/**
* Packets that travel along polylines with fading trails. Call `update(dt)`
* every frame; add `points` to the scene.
*/
export function createPackets(
stage: Stage,
paths: THREE.Vector3[][],
o: { perPath?: number; speed?: number; trail?: number; size?: number } = {},
) {
const trail = o.trail ?? 6;
const perPath = o.perPath ?? 1;
const packets = paths.flatMap((path, pi) =>
Array.from({ length: perPath }, (_, k) => ({
path: pi,
t: (k / perPath + Math.random() * 0.3) % 1,
speed: (o.speed ?? 0.25) * (0.7 + Math.random() * 0.6),
history: [] as THREE.Vector3[],
})),
);
const count = packets.length * trail;
const geo = glowGeometry(new Float32Array(count * 3), { dynamic: true });
const pos = geo.attributes.position as THREE.BufferAttribute;
const alpha = geo.attributes.aAlpha as THREE.BufferAttribute;
const size = geo.attributes.aSize as THREE.BufferAttribute;
// hidden until the first update (never shown for reduced motion)
(alpha.array as Float32Array).fill(0);
const mat = glowPoints(stage, {
color: stage.colors.accent,
size: o.size ?? 9,
});
const points = new THREE.Points(geo, mat);
points.frustumCulled = false;
// measured every update so paths may move (e.g. bobbing layers)
const lengths = new Float32Array(paths.length);
const measure = () =>
paths.forEach((p, pi) => {
let L = 0;
for (let i = 1; i < p.length; i++) L += p[i]!.distanceTo(p[i - 1]!);
lengths[pi] = L;
});
const tmp = new THREE.Vector3();
function sample(path: THREE.Vector3[], t: number, out: THREE.Vector3) {
if (path.length < 2) return out.copy(path[0] ?? out);
const f = (Number.isFinite(t) ? t : 0) * (path.length - 1);
const i = Math.min(Math.max(Math.floor(f), 0), path.length - 2);
return out.lerpVectors(path[i]!, path[i + 1]!, f - i);
}
return {
points,
material: mat,
/**
* @param visibility global 0→1 multiplier
* @param pathVisibility optional per-packet gate (path index, head t)
*/
update(
dt: number,
visibility = 1,
pathVisibility?: (path: number, t: number) => number,
) {
measure();
let idx = 0;
for (const p of packets) {
const path = paths[p.path]!;
p.t =
(p.t + (dt * p.speed) / Math.max(lengths[p.path]!, 0.01)) %
1;
if (!Number.isFinite(p.t)) p.t = 0;
sample(path, p.t, tmp);
if (!p.history.length) {
for (let k = 0; k < trail; k++) p.history.push(tmp.clone());
}
for (let k = trail - 1; k > 0; k--) {
p.history[k]!.copy(p.history[k - 1]!);
}
p.history[0]!.copy(tmp);
// fade near path ends so wrap-around isn't visible
const edge =
smooth(Math.min(p.t, 1 - p.t) * 12) *
(pathVisibility ? pathVisibility(p.path, p.t) : 1);
for (let k = 0; k < trail; k++) {
const h = p.history[k]!;
pos.setXYZ(idx, h.x, h.y, h.z);
const f = 1 - k / trail;
alpha.setX(idx, f * f * edge * visibility);
size.setX(idx, 0.4 + f * 0.6);
idx++;
}
}
pos.needsUpdate = true;
alpha.needsUpdate = true;
size.needsUpdate = true;
},
};
}
+435
View File
@@ -0,0 +1,435 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import { smooth } from "../materials";
/**
* Home hero: my face as a network graph.
*
* Mesh data generated offline from /public/images/my-photo.png with
* MediaPipe Face Landmarker: `v` = flat xyz (normalized ~[-1, 1], y up,
* +z toward camera), `e` = tessellation edge pairs, `c` = contour edge pairs
* (eyes, brows, lips, face oval).
*
* Entry: nodes fly in from a scattered cloud, edges connect once both ends
* have arrived, a bright scanline sweeps the build, then packets start
* routing across the network. Scrolling away blows it apart again.
*/
type FaceMeshData = { v: number[]; e: number[]; c: number[] };
const PACKET_COUNT = 70;
const PACKET_TRAIL = 5;
const DUST_COUNT = 260;
const DEPTH_EXAGGERATION = 1.35;
const sharedVertex = /* glsl */ `
attribute vec3 aRandom;
uniform float uTime;
uniform float uDissolve;
uniform float uEntry;
uniform float uScanY;
uniform vec3 uWaveOrigin;
uniform float uWaveRadius;
varying float vGlow;
varying float vFacing;
varying float vArrive;
// per-node arrival: staggered by a random delay, eased
float arrival(float span) {
float local = clamp((uEntry - aRandom.x * (1.0 - span)) / span, 0.0, 1.0);
return local * local * (3.0 - 2.0 * local);
}
vec3 displaced(vec3 p) {
p += (aRandom - 0.5) * 0.012 * sin(uTime * 1.3 + aRandom.x * 40.0);
vec3 dir = normalize(aRandom - 0.5 + vec3(0.0, 0.0, 0.35));
float scatter = max(smoothstep(0.0, 1.0, uDissolve), 1.0 - arrival(0.45));
p += dir * scatter * (1.4 + aRandom.y * 2.6);
return p;
}
float glowAt(vec3 p) {
float scan = exp(-pow((p.y - uScanY) * 9.0, 2.0)) * 0.9;
float ring = abs(distance(p, uWaveOrigin) - uWaveRadius);
float wave = exp(-pow(ring * 14.0, 2.0)) * smoothstep(2.4, 0.4, uWaveRadius);
return scan + wave * 1.4;
}
float facing(vec3 p) {
vec3 n = normalize((modelMatrix * vec4(p * vec3(1.0, 1.0, 1.6), 0.0)).xyz);
return clamp(n.z * 0.5 + 0.55, 0.0, 1.0);
}
`;
const lineVertex = /* glsl */ `
${sharedVertex}
void main() {
vec3 p = displaced(position);
vGlow = glowAt(position);
vFacing = facing(position);
// edges connect after nodes land
vArrive = clamp((uEntry - 0.35 - aRandom.x * 0.4) / 0.25, 0.0, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`;
const lineFragment = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uAccent;
uniform float uOpacity;
uniform float uDissolve;
varying float vGlow;
varying float vFacing;
varying float vArrive;
void main() {
vec3 col = mix(uColor, uAccent, clamp(vGlow, 0.0, 1.0) * 0.8);
float a = uOpacity * (0.25 + vFacing * 0.75) + vGlow * 0.35;
a *= 1.0 - smoothstep(0.0, 0.55, uDissolve);
a *= vArrive;
gl_FragColor = vec4(col, a);
}
`;
const nodeVertex = /* glsl */ `
${sharedVertex}
uniform float uPixelRatio;
uniform float uSize;
void main() {
vec3 p = displaced(position);
vGlow = glowAt(position);
vFacing = facing(position);
vArrive = arrival(0.45);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
// nodes flare while in flight
float flight = 1.0 + (1.0 - vArrive) * 0.8;
float size = uSize * (0.6 + aRandom.z * 0.8) * (1.0 + vGlow * 1.2) * flight;
gl_PointSize = min(size * (4.0 / -mv.z), 14.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`;
const nodeFragment = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uAccent;
uniform float uDissolve;
varying float vGlow;
varying float vFacing;
varying float vArrive;
void main() {
float r = length(gl_PointCoord - 0.5);
float core = smoothstep(0.5, 0.0, r);
float a = core * core * (0.35 + vFacing * 0.65 + vGlow);
a *= 1.0 - smoothstep(0.6, 1.0, uDissolve);
a *= smoothstep(0.0, 0.25, vArrive + 0.08);
vec3 col = mix(uColor, uAccent, clamp(vGlow + (1.0 - vArrive) * 0.7, 0.0, 1.0));
gl_FragColor = vec4(col, a);
}
`;
const packetVertex = /* glsl */ `
attribute float aAlpha;
uniform float uPixelRatio;
uniform float uSize;
varying float vAlpha;
void main() {
vAlpha = aAlpha;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = min(uSize * (0.35 + aAlpha * 0.65) * (4.0 / -mv.z), 16.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`;
const packetFragment = /* glsl */ `
uniform vec3 uAccent;
uniform float uDissolve;
uniform float uVisible;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - 0.5);
float core = smoothstep(0.5, 0.0, r);
float a = pow(core, 1.6) * vAlpha * uVisible * (1.0 - smoothstep(0.0, 0.4, uDissolve));
gl_FragColor = vec4(mix(uAccent, vec3(1.0), core * 0.5), a);
}
`;
const dustVertex = /* glsl */ `
attribute float aSeed;
uniform float uTime;
uniform float uPixelRatio;
uniform float uEntry;
varying float vAlpha;
void main() {
vec3 p = position;
p.y += mod(uTime * 0.03 * (0.5 + aSeed) + aSeed * 10.0, 4.0) - 2.0;
p.x += sin(uTime * 0.2 + aSeed * 20.0) * 0.05;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
vAlpha = (0.15 + aSeed * 0.35) * (1.0 - smoothstep(1.6, 2.0, abs(p.y))) * uEntry;
gl_PointSize = (1.0 + aSeed * 2.0) * uPixelRatio * (4.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`;
const dustFragment = /* glsl */ `
uniform vec3 uColor;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - 0.5);
gl_FragColor = vec4(uColor, smoothstep(0.5, 0.0, r) * vAlpha);
}
`;
const face: SceneFactory = async (stage) => {
const data: FaceMeshData = await fetch("/data/face-mesh.json").then((r) =>
r.json(),
);
const { scene, reducedMotion } = stage;
const vertexCount = data.v.length / 3;
const basePositions = new Float32Array(data.v);
for (let i = 2; i < basePositions.length; i += 3) {
basePositions[i]! *= DEPTH_EXAGGERATION;
}
const randoms = new Float32Array(vertexCount * 3);
for (let i = 0; i < randoms.length; i++) randoms[i] = Math.random();
const u = {
uTime: { value: 0 },
uDissolve: { value: 0 },
uEntry: { value: stage.entry },
uScanY: { value: -3 },
uWaveOrigin: { value: new THREE.Vector3() },
uWaveRadius: { value: 10 },
uColor: { value: stage.colors.line },
uAccent: { value: stage.colors.accent },
uPixelRatio: { value: stage.pixelRatio },
};
const makeGeometry = (index?: number[]) => {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(basePositions, 3));
g.setAttribute("aRandom", new THREE.BufferAttribute(randoms, 3));
if (index) g.setIndex(index);
return g;
};
const additive = {
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
} as const;
const group = new THREE.Group();
scene.add(group);
group.add(
new THREE.LineSegments(
makeGeometry(data.e),
new THREE.ShaderMaterial({
vertexShader: lineVertex,
fragmentShader: lineFragment,
uniforms: { ...u, uOpacity: { value: 0.22 } },
...additive,
}),
),
new THREE.LineSegments(
makeGeometry(data.c),
new THREE.ShaderMaterial({
vertexShader: lineVertex,
fragmentShader: lineFragment,
uniforms: { ...u, uOpacity: { value: 0.55 } },
...additive,
}),
),
new THREE.Points(
makeGeometry(),
new THREE.ShaderMaterial({
vertexShader: nodeVertex,
fragmentShader: nodeFragment,
uniforms: { ...u, uSize: { value: 5.5 } },
...additive,
}),
),
);
// ---- packets: random walks over the mesh graph ----
const adjacency: number[][] = Array.from({ length: vertexCount }, () => []);
for (let i = 0; i < data.e.length; i += 2) {
adjacency[data.e[i]!]!.push(data.e[i + 1]!);
adjacency[data.e[i + 1]!]!.push(data.e[i]!);
}
const vertexPos = (i: number, out: THREE.Vector3) =>
out.set(
basePositions[i * 3]!,
basePositions[i * 3 + 1]!,
basePositions[i * 3 + 2]!,
);
const randomVertex = () => Math.floor(Math.random() * vertexCount);
type Packet = {
from: number;
to: number;
t: number;
speed: number;
history: THREE.Vector3[];
};
const packets: Packet[] = Array.from({ length: PACKET_COUNT }, () => {
const from = randomVertex();
const start = vertexPos(from, new THREE.Vector3());
return {
from,
to: adjacency[from]![0] ?? from,
t: Math.random(),
speed: 2.2 + Math.random() * 2.5,
history: Array.from({ length: PACKET_TRAIL }, () => start.clone()),
};
});
const nextHop = (p: Packet) => {
const options = adjacency[p.to]!.filter((n) => n !== p.from);
const pick = options.length
? options[Math.floor(Math.random() * options.length)]!
: p.from;
p.from = p.to;
p.to = pick;
p.t = 0;
};
const packetPositions = new Float32Array(PACKET_COUNT * PACKET_TRAIL * 3);
const packetAlphas = new Float32Array(PACKET_COUNT * PACKET_TRAIL);
const packetGeo = new THREE.BufferGeometry();
packetGeo.setAttribute(
"position",
new THREE.BufferAttribute(packetPositions, 3),
);
packetGeo.setAttribute(
"aAlpha",
new THREE.BufferAttribute(packetAlphas, 1),
);
const packetVisible = { value: 0 };
const packetPoints = new THREE.Points(
packetGeo,
new THREE.ShaderMaterial({
vertexShader: packetVertex,
fragmentShader: packetFragment,
uniforms: {
uAccent: u.uAccent,
uDissolve: u.uDissolve,
uPixelRatio: u.uPixelRatio,
uSize: { value: 9 },
uVisible: packetVisible,
},
...additive,
}),
);
packetPoints.frustumCulled = false;
group.add(packetPoints);
// ---- ambient dust ----
const dustPositions = new Float32Array(DUST_COUNT * 3);
const dustSeeds = new Float32Array(DUST_COUNT);
for (let i = 0; i < DUST_COUNT; i++) {
dustPositions[i * 3] = (Math.random() - 0.5) * 5;
dustPositions[i * 3 + 1] = (Math.random() - 0.5) * 4;
dustPositions[i * 3 + 2] = (Math.random() - 0.5) * 3 - 0.5;
dustSeeds[i] = Math.random();
}
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute(
"position",
new THREE.BufferAttribute(dustPositions, 3),
);
dustGeo.setAttribute("aSeed", new THREE.BufferAttribute(dustSeeds, 1));
const dust = new THREE.Points(
dustGeo,
new THREE.ShaderMaterial({
vertexShader: dustVertex,
fragmentShader: dustFragment,
uniforms: {
uTime: u.uTime,
uPixelRatio: u.uPixelRatio,
uColor: u.uColor,
uEntry: u.uEntry,
},
...additive,
}),
);
dust.frustumCulled = false;
scene.add(dust);
const tmpA = new THREE.Vector3();
const tmpB = new THREE.Vector3();
const current = new THREE.Vector3();
let waveStart = -10;
let nextWave = 4;
function updatePackets(dt: number) {
let idx = 0;
for (const p of packets) {
p.t += dt * p.speed;
while (p.t >= 1) nextHop(p);
current.lerpVectors(
vertexPos(p.from, tmpA),
vertexPos(p.to, tmpB),
p.t,
);
for (let k = PACKET_TRAIL - 1; k > 0; k--) {
p.history[k]!.copy(p.history[k - 1]!);
}
p.history[0]!.copy(current);
for (let k = 0; k < PACKET_TRAIL; k++) {
const h = p.history[k]!;
packetPositions.set([h.x, h.y, h.z], idx * 3);
packetAlphas[idx] = Math.pow(1 - k / PACKET_TRAIL, 1.8);
idx++;
}
}
packetGeo.attributes.position!.needsUpdate = true;
packetGeo.attributes.aAlpha!.needsUpdate = true;
}
return {
frame: { width: 2.3, height: 2.8 },
update(t, dt) {
const entry = stage.entry;
u.uTime.value = t;
u.uEntry.value = entry;
u.uDissolve.value = stage.scroll;
if (reducedMotion) {
packetPoints.visible = false;
group.rotation.set(0.04, -0.28, 0);
return;
}
// one bright sweep top → bottom during the build, then the
// regular slow scan cycle
u.uScanY.value =
entry < 1 ? 1.4 - entry * 3.2 : 1.6 - (((t - 3) * 0.55) % 3.6);
if (entry >= 1 && t > nextWave) {
waveStart = t;
nextWave = t + 4 + Math.random() * 3;
vertexPos(randomVertex(), u.uWaveOrigin.value);
}
u.uWaveRadius.value = (t - waveStart) * 1.1;
packetVisible.value = smooth((entry - 0.75) / 0.25);
updatePackets(dt);
// camera-ish dolly + spin settling into place
const settle = 1 - entry;
const targetY =
stage.pointer.x * 0.7 +
Math.sin(t * 0.25) * 0.15 -
settle * 0.9;
const targetX = stage.pointer.y * 0.3 + Math.sin(t * 0.18) * 0.05;
const k = Math.min(dt * 3, 1);
group.rotation.y += (targetY - group.rotation.y) * k;
group.rotation.x += (targetX - group.rotation.x) * k;
group.position.z = -settle * 1.2;
group.scale.setScalar(1 + Math.sin(t * 0.9) * 0.008);
dust.rotation.y = group.rotation.y * 0.3;
},
};
};
export default face;
+260
View File
@@ -0,0 +1,260 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
} from "../materials";
/**
* Contact: a dotted wireframe globe with connection arcs reaching out from
* home base to cities around the world — "I work with teams anywhere".
*/
const R = 1;
/** Home base. */
const ORIGIN = { lat: 6.52, lon: 3.38 }; // Lagos
const DESTINATIONS = [
{ lat: 51.5, lon: -0.13 }, // London
{ lat: 40.71, lon: -74.0 }, // New York
{ lat: 37.77, lon: -122.42 }, // San Francisco
{ lat: 43.65, lon: -79.38 }, // Toronto
{ lat: 52.52, lon: 13.4 }, // Berlin
{ lat: 25.2, lon: 55.27 }, // Dubai
{ lat: 1.35, lon: 103.82 }, // Singapore
{ lat: -26.2, lon: 28.05 }, // Johannesburg
{ lat: -23.55, lon: -46.63 }, // São Paulo
{ lat: 59.33, lon: 18.07 }, // Stockholm
];
const DOTS = 1600;
const ARC_SEGMENTS = 64;
function latLon(lat: number, lon: number, r = R) {
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon + 180);
return new THREE.Vector3(
-r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta),
);
}
function arcPoints(a: THREE.Vector3, b: THREE.Vector3) {
const angle = a.angleTo(b);
const lift = 0.06 + (angle / Math.PI) * 0.28;
const pts: THREE.Vector3[] = [];
const qa = new THREE.Quaternion();
const axis = new THREE.Vector3().crossVectors(a, b).normalize();
for (let i = 0; i <= ARC_SEGMENTS; i++) {
const t = i / ARC_SEGMENTS;
qa.setFromAxisAngle(axis, angle * t);
const p = a.clone().applyQuaternion(qa);
p.multiplyScalar(1 + lift * Math.sin(Math.PI * t));
pts.push(p);
}
return pts;
}
const globe: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
const spin = new THREE.Group();
root.add(spin);
scene.add(root);
const origin = latLon(ORIGIN.lat, ORIGIN.lon);
// face home base toward the camera, tilted to show a bit more north
const baseY = Math.atan2(-origin.x, origin.z);
root.rotation.x = 0.35;
// ---- dots: fibonacci lattice ----
const dotPos = new Float32Array(DOTS * 3);
const dotAngle = new Float32Array(DOTS);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < DOTS; i++) {
const y = 1 - (i / (DOTS - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const th = golden * i;
const p = new THREE.Vector3(Math.cos(th) * r, y, Math.sin(th) * r);
dotPos.set([p.x, p.y, p.z], i * 3);
dotAngle[i] = p.angleTo(origin);
}
const dotGeo = glowGeometry(dotPos, { dynamic: true });
const dotAlpha = dotGeo.attributes.aAlpha as THREE.BufferAttribute;
const dots = new THREE.Points(
dotGeo,
glowPoints(stage, { color: colors.line, size: 3.2, opacity: 0.8 }),
);
spin.add(dots);
// ---- graticule ----
const grat: number[] = [];
const ring = (fn: (a: number) => THREE.Vector3) => {
const N = 96;
for (let i = 0; i < N; i++) {
const a = fn((i / N) * Math.PI * 2);
const b = fn(((i + 1) / N) * Math.PI * 2);
grat.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
};
for (let lat = -60; lat <= 60; lat += 30) {
ring((a) => latLon(lat, THREE.MathUtils.radToDeg(a)));
}
for (let lon = 0; lon < 180; lon += 30) {
ring((a) => {
const p = new THREE.Vector3(Math.cos(a), Math.sin(a), 0);
return p.applyAxisAngle(
new THREE.Vector3(0, 1, 0),
THREE.MathUtils.degToRad(lon),
);
});
}
const gratMat = glowLines({ color: colors.line, opacity: 0.1 });
spin.add(new THREE.LineSegments(glowGeometry(grat), gratMat));
// ---- arcs ----
const arcs = DESTINATIONS.map((d, i) => {
const pts = arcPoints(origin, latLon(d.lat, d.lon));
const pos: number[] = [];
const ts: number[] = [];
for (let k = 0; k < pts.length - 1; k++) {
const a = pts[k]!;
const b = pts[k + 1]!;
pos.push(a.x, a.y, a.z, b.x, b.y, b.z);
ts.push(k / ARC_SEGMENTS, (k + 1) / ARC_SEGMENTS);
}
const geo = glowGeometry(pos);
geo.setAttribute(
"aT",
new THREE.BufferAttribute(new Float32Array(ts), 1),
);
const mat = glowLines({ color: colors.accent, opacity: 0.9 });
mat.uniforms.uHead = { value: 0 };
mat.uniforms.uTail = { value: 0 };
mat.vertexShader = mat.vertexShader
.replace(
"attribute float aAlpha;",
"attribute float aAlpha;\nattribute float aT;\nvarying float vT;",
)
.replace("vAlpha = aAlpha", "vT = aT;\nvAlpha = aAlpha");
mat.fragmentShader = mat.fragmentShader
.replace(
"varying float vAlpha;",
"varying float vAlpha;\nvarying float vT;\nuniform float uHead;\nuniform float uTail;",
)
.replace(
"gl_FragColor = vec4(uColor, vAlpha * uOpacity);",
`float drawn = step(uTail, vT) * step(vT, uHead);
float head = exp(-pow((vT - uHead) * 18.0, 2.0));
gl_FragColor = vec4(mix(uColor, vec3(1.0), head * 0.6), vAlpha * uOpacity * drawn * (0.35 + head));`,
);
spin.add(new THREE.LineSegments(geo, mat));
// cycle: draw (1.6s) → hold → retract → wait
return {
pts,
mat,
offset: 0.9 + i * 0.45,
period: 7 + Math.random() * 3,
};
});
// ---- landing pins + home base pulse ----
const pinPos: number[] = [];
DESTINATIONS.forEach((d) => {
const p = latLon(d.lat, d.lon, R * 1.005);
pinPos.push(p.x, p.y, p.z);
});
const pins = new THREE.Points(
glowGeometry(pinPos),
glowPoints(stage, { color: colors.accent, size: 6, opacity: 0.7 }),
);
spin.add(pins);
const home = new THREE.Points(
glowGeometry([origin.x * 1.01, origin.y * 1.01, origin.z * 1.01]),
glowPoints(stage, { color: colors.accent, size: 16 }),
);
spin.add(home);
const pulseRing: number[] = [];
for (let i = 0; i < 64; i++) {
const a = (i / 64) * Math.PI * 2;
const b = ((i + 1) / 64) * Math.PI * 2;
pulseRing.push(
Math.cos(a),
Math.sin(a),
0,
Math.cos(b),
Math.sin(b),
0,
);
}
const pulseMat = glowLines({ color: colors.accent, opacity: 0.8 });
const pulse = new THREE.LineSegments(glowGeometry(pulseRing), pulseMat);
pulse.position.copy(origin).multiplyScalar(1.01);
pulse.lookAt(origin.clone().multiplyScalar(2));
pulse.scale.setScalar(0.001);
pulse.visible = !stage.reducedMotion;
spin.add(pulse);
// ---- packets along arcs ----
const packets = createPackets(
stage,
arcs.map((a) => a.pts),
{ perPath: 1, speed: 0.7, trail: 8, size: 8 },
);
spin.add(packets.points);
// static frame for reduced motion: everything drawn
if (stage.reducedMotion) {
arcs.forEach((a) => (a.mat.uniforms.uHead!.value = 1));
}
return {
frame: { width: 2.9, height: 2.9 },
update(t, dt) {
const e = stage.entry;
// dots switch on in a wave spreading out from home base
const front = e * Math.PI * 1.15;
for (let i = 0; i < DOTS; i++) {
dotAlpha.setX(i, smooth((front - dotAngle[i]!) / 0.5));
}
dotAlpha.needsUpdate = true;
gratMat.uniforms.uOpacity!.value = 0.1 * smooth((e - 0.3) / 0.5);
root.scale.setScalar(0.82 + 0.18 * smooth(e));
spin.rotation.y =
baseY +
(1 - smooth(e)) * 1.4 +
Math.sin(t * 0.12) * 0.45 +
stage.pointer.x * 0.35;
root.rotation.x = 0.35 + stage.pointer.y * 0.15;
if (stage.reducedMotion) return;
const armed = e > 0.55;
arcs.forEach((a) => {
const local = armed ? (t - a.offset) % a.period : -1;
const head = local < 0 ? 0 : smooth(local / 1.6);
const tail =
local < 0 ? 0 : smooth((local - a.period + 2.2) / 1.6);
a.mat.uniforms.uHead!.value = head;
a.mat.uniforms.uTail!.value = tail;
});
const pt = (t % 2.4) / 2.4;
pulse.scale.setScalar(0.02 + pt * 0.16);
pulseMat.uniforms.uOpacity!.value = (1 - pt) * 0.9 * e;
packets.update(dt, smooth((e - 0.8) / 0.2), (i, pt) => {
const u = arcs[i]!.mat.uniforms;
return pt > u.uTail!.value && pt < u.uHead!.value ? 1 : 0;
});
},
};
};
export default globe;
+190
View File
@@ -0,0 +1,190 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
} from "../materials";
/**
* About: a career double helix — two strands (build & operate) bound by
* rungs, with highlighted milestone rungs. Entry: builds bottom → top.
*/
const STEPS = 90;
const HEIGHT = 3;
const RADIUS = 0.55;
const TURNS = 2.25;
const RUNG_EVERY = 3;
const MILESTONES = [6, 13, 19, 24]; // rung indices (career milestones)
const helix: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.z = 0.18;
scene.add(root);
const spin = new THREE.Group();
root.add(spin);
const strandA: THREE.Vector3[] = [];
const strandB: THREE.Vector3[] = [];
for (let i = 0; i <= STEPS; i++) {
const t = i / STEPS;
const a = t * TURNS * Math.PI * 2;
const y = -HEIGHT / 2 + t * HEIGHT;
strandA.push(
new THREE.Vector3(Math.cos(a) * RADIUS, y, Math.sin(a) * RADIUS),
);
strandB.push(
new THREE.Vector3(
Math.cos(a + Math.PI) * RADIUS,
y,
Math.sin(a + Math.PI) * RADIUS,
),
);
}
// strands
const strandPos: number[] = [];
const strandY: number[] = [];
[strandA, strandB].forEach((s) => {
for (let i = 0; i < s.length - 1; i++) {
strandPos.push(
s[i]!.x,
s[i]!.y,
s[i]!.z,
s[i + 1]!.x,
s[i + 1]!.y,
s[i + 1]!.z,
);
strandY.push(s[i]!.y, s[i + 1]!.y);
}
});
const strandGeo = glowGeometry(strandPos, { dynamic: true });
spin.add(
new THREE.LineSegments(
strandGeo,
glowLines({ color: colors.line, opacity: 0.7 }),
),
);
// rungs
const rungPos: number[] = [];
const rungY: number[] = [];
const rungMid: number[] = [];
const milestonePos: number[] = [];
let rung = 0;
for (let i = 0; i <= STEPS; i += RUNG_EVERY, rung++) {
const a = strandA[i]!;
const b = strandB[i]!;
rungPos.push(a.x, a.y, a.z, b.x, b.y, b.z);
rungY.push(a.y, b.y);
// 3 "base pair" dots along each rung
for (const f of [0.3, 0.5, 0.7]) {
const m = a.clone().lerp(b, f);
rungMid.push(m.x, m.y, m.z);
}
if (MILESTONES.includes(rung)) {
milestonePos.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
}
const rungGeo = glowGeometry(rungPos, { dynamic: true });
spin.add(
new THREE.LineSegments(
rungGeo,
glowLines({ color: colors.line, opacity: 0.18 }),
),
);
const midGeo = glowGeometry(rungMid, { dynamic: true });
spin.add(
new THREE.Points(
midGeo,
glowPoints(stage, { color: colors.line, size: 2.5, opacity: 0.6 }),
),
);
// strand nodes
const nodeGeo = glowGeometry(
[...strandA, ...strandB].flatMap((v) => [v.x, v.y, v.z]),
{ dynamic: true },
);
spin.add(
new THREE.Points(
nodeGeo,
glowPoints(stage, { color: colors.line, size: 4 }),
),
);
// milestones
const milestoneGeo = glowGeometry(milestonePos, { dynamic: true });
const milestoneMat = glowPoints(stage, { color: colors.accent, size: 15 });
spin.add(new THREE.Points(milestoneGeo, milestoneMat));
const milestoneLinks = glowGeometry(milestonePos, { dynamic: true });
spin.add(
new THREE.LineSegments(
milestoneLinks,
glowLines({ color: colors.accent, opacity: 0.8 }),
),
);
// packets climbing the strands
const packets = createPackets(stage, [strandA, strandB], {
perPath: 3,
speed: 0.6,
trail: 10,
size: 9,
});
spin.add(packets.points);
// per-geometry y lookup for the bottom-up build
const yOf = (g: THREE.BufferGeometry) => {
const p = g.attributes.position as THREE.BufferAttribute;
return Float32Array.from({ length: p.count }, (_, i) => p.getY(i));
};
const builds = [
strandGeo,
rungGeo,
midGeo,
nodeGeo,
milestoneGeo,
milestoneLinks,
].map((g, k) => ({
g,
y: yOf(g),
lag: k === 1 || k === 2 ? 0.25 : k >= 4 ? 0.4 : 0,
}));
return {
frame: { width: 2.4, height: 3.9 },
update(t, dt) {
const e = stage.entry;
if (e < 1 || stage.reducedMotion) {
for (const b of builds) {
const a = b.g.attributes.aAlpha as THREE.BufferAttribute;
const front = -HEIGHT / 2 + (e * 1.3 - b.lag) * HEIGHT;
for (let i = 0; i < b.y.length; i++) {
a.setX(i, smooth((front - b.y[i]!) / 0.35));
}
a.needsUpdate = true;
}
}
if (stage.reducedMotion) {
spin.rotation.y = 0.6;
return;
}
const pulse = 0.75 + Math.sin(t * 2.2) * 0.25;
milestoneMat.uniforms.uOpacity!.value = pulse;
packets.update(dt, smooth((e - 0.8) / 0.2));
spin.rotation.y = t * 0.25 + (1 - smooth(e)) * -2;
root.rotation.x = stage.pointer.y * 0.2;
root.rotation.y = stage.pointer.x * 0.4;
},
};
};
export default helix;
+206
View File
@@ -0,0 +1,206 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
} from "../materials";
/**
* Skills: a geodesic "tech stack" sphere. Every node is a tool, every edge
* an integration; a glowing core spins inside and satellites orbit on
* tilted rings. Entry: the lattice grows out from the core.
*/
function uniqueVerticesAndEdges(geo: THREE.BufferGeometry) {
const pos = geo.attributes.position as THREE.BufferAttribute;
const keyOf = (i: number) =>
`${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}`;
const map = new Map<string, number>();
const verts: THREE.Vector3[] = [];
const remap: number[] = [];
for (let i = 0; i < pos.count; i++) {
const k = keyOf(i);
if (!map.has(k)) {
map.set(k, verts.length);
verts.push(
new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i)),
);
}
remap.push(map.get(k)!);
}
const edges = new Set<string>();
for (let i = 0; i < remap.length; i += 3) {
const tri = [remap[i]!, remap[i + 1]!, remap[i + 2]!];
for (let j = 0; j < 3; j++) {
const a = tri[j]!;
const b = tri[(j + 1) % 3]!;
edges.add(a < b ? `${a}-${b}` : `${b}-${a}`);
}
}
return {
verts,
edges: [...edges].map(
(e) => e.split("-").map(Number) as [number, number],
),
};
}
const sphere: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
scene.add(root);
// ---- outer geodesic lattice ----
const { verts, edges } = uniqueVerticesAndEdges(
new THREE.IcosahedronGeometry(1, 3),
);
const vertPos = new Float32Array(verts.length * 3);
verts.forEach((v, i) => vertPos.set([v.x, v.y, v.z], i * 3));
const index = edges.flat();
const latticeGeo = glowGeometry(vertPos.slice(), { index, dynamic: true });
const latticeMat = glowLines({ color: colors.line, opacity: 0.22 });
const lattice = new THREE.LineSegments(latticeGeo, latticeMat);
const nodeGeo = glowGeometry(vertPos.slice(), { dynamic: true });
const nodeAlpha = nodeGeo.attributes.aAlpha as THREE.BufferAttribute;
const nodeSize = nodeGeo.attributes.aSize as THREE.BufferAttribute;
const nodes = new THREE.Points(
nodeGeo,
glowPoints(stage, { color: colors.line, size: 4.5 }),
);
// a few highlighted "core skills"
const hubs = new Set<number>();
while (hubs.size < 14) hubs.add(Math.floor(Math.random() * verts.length));
const hubPos: number[] = [];
hubs.forEach((i) => hubPos.push(verts[i]!.x, verts[i]!.y, verts[i]!.z));
const hubGeo = glowGeometry(hubPos);
const hubMat = glowPoints(stage, { color: colors.accent, size: 11 });
const hubPoints = new THREE.Points(hubGeo, hubMat);
const shell = new THREE.Group();
shell.add(lattice, nodes, hubPoints);
root.add(shell);
// ---- inner core ----
const coreData = uniqueVerticesAndEdges(
new THREE.IcosahedronGeometry(0.38, 0),
);
const corePos: number[] = [];
coreData.edges.forEach(([a, b]) => {
const va = coreData.verts[a]!;
const vb = coreData.verts[b]!;
corePos.push(va.x, va.y, va.z, vb.x, vb.y, vb.z);
});
const coreMat = glowLines({ color: colors.accent, opacity: 0.7 });
const core = new THREE.LineSegments(glowGeometry(corePos), coreMat);
root.add(core);
// spokes from core to hubs
const spokePos: number[] = [];
hubs.forEach((i) => {
const v = verts[i]!;
const s = v.clone().multiplyScalar(0.38);
spokePos.push(s.x, s.y, s.z, v.x, v.y, v.z);
});
const spokeMat = glowLines({ color: colors.accent, opacity: 0.12 });
shell.add(new THREE.LineSegments(glowGeometry(spokePos), spokeMat));
// ---- orbit rings + satellites ----
const ringPaths: THREE.Vector3[][] = [];
const ringMats: THREE.ShaderMaterial[] = [];
const rings = new THREE.Group();
[
{ r: 1.35, tilt: new THREE.Euler(1.2, 0, 0.3) },
{ r: 1.5, tilt: new THREE.Euler(0.4, 0.2, -0.9) },
{ r: 1.62, tilt: new THREE.Euler(-0.7, 0, 1.4) },
].forEach(({ r, tilt }) => {
const path: THREE.Vector3[] = [];
const pos: number[] = [];
const N = 160;
for (let i = 0; i <= N; i++) {
const a = (i / N) * Math.PI * 2;
path.push(
new THREE.Vector3(
Math.cos(a) * r,
0,
Math.sin(a) * r,
).applyEuler(tilt),
);
}
for (let i = 0; i < N; i++) {
const a = path[i]!;
const b = path[i + 1]!;
pos.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
const mat = glowLines({ color: colors.line, opacity: 0.12 });
ringMats.push(mat);
rings.add(new THREE.LineSegments(glowGeometry(pos), mat));
ringPaths.push(path);
});
root.add(rings);
const satellites = createPackets(stage, ringPaths, {
perPath: 2,
speed: 0.9,
trail: 10,
size: 9,
});
rings.add(satellites.points);
return {
frame: { width: 3.6, height: 3.6 },
update(t, dt) {
const e = stage.entry;
const grow = smooth(e / 0.7);
// lattice grows out from the core with a slight overshoot wobble
const pos = latticeGeo.attributes.position as THREE.BufferAttribute;
const npos = nodeGeo.attributes.position as THREE.BufferAttribute;
for (let i = 0; i < verts.length; i++) {
const v = verts[i]!;
// pulse travelling over the sphere surface
const wave = Math.sin(v.y * 4 - t * 1.8 + v.x * 2) * 0.5 + 0.5;
const r = grow * (1 + wave * 0.025);
pos.setXYZ(i, v.x * r, v.y * r, v.z * r);
npos.setXYZ(i, v.x * r, v.y * r, v.z * r);
nodeAlpha.setX(i, (0.35 + wave * 0.65) * grow);
nodeSize.setX(i, 0.7 + wave * 0.6);
}
pos.needsUpdate = true;
npos.needsUpdate = true;
nodeAlpha.needsUpdate = true;
nodeSize.needsUpdate = true;
latticeMat.uniforms.uOpacity!.value = 0.22 * grow;
hubPoints.scale.setScalar(grow);
hubMat.uniforms.uOpacity!.value = smooth((e - 0.5) / 0.4);
spokeMat.uniforms.uOpacity!.value = 0.12 * smooth((e - 0.6) / 0.4);
coreMat.uniforms.uOpacity!.value = 0.7 * smooth(e / 0.3);
core.scale.setScalar(0.4 + 0.6 * smooth(e / 0.4));
ringMats.forEach(
(m, i) =>
(m.uniforms.uOpacity!.value =
0.12 * smooth((e - 0.55 - i * 0.1) / 0.3)),
);
rings.scale.setScalar(0.85 + 0.15 * smooth((e - 0.5) / 0.5));
if (stage.reducedMotion) {
root.rotation.set(0.3, 0.6, 0);
return;
}
satellites.update(dt, smooth((e - 0.75) / 0.25));
shell.rotation.y = t * 0.08 + (1 - grow) * 1.5;
core.rotation.set(t * 0.3, -t * 0.45, 0);
rings.rotation.y = -t * 0.05;
root.rotation.x = 0.2 + stage.pointer.y * 0.3;
root.rotation.y = stage.pointer.x * 0.5;
},
};
};
export default sphere;
+183
View File
@@ -0,0 +1,183 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
import {
createPackets,
glowGeometry,
glowLines,
glowPoints,
smooth,
stagger,
} from "../materials";
/**
* Work: an isometric infrastructure stack — edge, app, data, storage
* layers as gridded plates, services as nodes, traffic flowing between
* layers. Entry: plates drop into place bottom-up, then links light up.
*/
const LAYERS = 4;
const SPACING = 0.62;
const SIZE = 1.9;
const GRID = 8;
const SERVICES_PER_LAYER = 5;
const LINKS_PER_GAP = 6;
const stack: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.set(0.62, Math.PI / 4, 0);
scene.add(root);
const half = SIZE / 2;
const cell = SIZE / GRID;
const layerY = (i: number) => (i - (LAYERS - 1) / 2) * SPACING;
type Layer = {
group: THREE.Group;
gridMat: THREE.ShaderMaterial;
borderMat: THREE.ShaderMaterial;
nodeMat: THREE.ShaderMaterial;
services: THREE.Vector3[];
};
const layers: Layer[] = [];
for (let li = 0; li < LAYERS; li++) {
const group = new THREE.Group();
const grid: number[] = [];
for (let k = 1; k < GRID; k++) {
const c = -half + k * cell;
grid.push(c, 0, -half, c, 0, half, -half, 0, c, half, 0, c);
}
const gridMat = glowLines({ color: colors.line, opacity: 0.1 });
group.add(new THREE.LineSegments(glowGeometry(grid), gridMat));
const b = [
[-half, -half],
[half, -half],
[half, half],
[-half, half],
];
const border: number[] = [];
for (let k = 0; k < 4; k++) {
const [x1, z1] = b[k]!;
const [x2, z2] = b[(k + 1) % 4]!;
border.push(x1!, 0, z1!, x2!, 0, z2!);
}
// corner ticks give plates a "hardware" feel
b.forEach(([x, z]) => {
border.push(x!, 0, z!, x!, -0.06, z!);
});
const borderMat = glowLines({ color: colors.line, opacity: 0.55 });
group.add(new THREE.LineSegments(glowGeometry(border), borderMat));
const services: THREE.Vector3[] = [];
const used = new Set<string>();
while (services.length < SERVICES_PER_LAYER) {
const gx = 1 + Math.floor(Math.random() * (GRID - 1));
const gz = 1 + Math.floor(Math.random() * (GRID - 1));
if (used.has(`${gx},${gz}`)) continue;
used.add(`${gx},${gz}`);
services.push(
new THREE.Vector3(-half + gx * cell, 0, -half + gz * cell),
);
}
const nodeMat = glowPoints(stage, {
color: li === LAYERS - 1 ? colors.accent : colors.line,
size: 9,
});
group.add(
new THREE.Points(
glowGeometry(services.flatMap((s) => [s.x, s.y, s.z])),
nodeMat,
),
);
root.add(group);
layers.push({ group, gridMat, borderMat, nodeMat, services });
}
// ---- inter-layer links ----
type Link = { lower: number; a: number; b: number };
const links: Link[] = [];
for (let li = 0; li < LAYERS - 1; li++) {
for (let k = 0; k < LINKS_PER_GAP; k++) {
links.push({
lower: li,
a: Math.floor(Math.random() * SERVICES_PER_LAYER),
b: Math.floor(Math.random() * SERVICES_PER_LAYER),
});
}
}
const linkGeo = glowGeometry(new Float32Array(links.length * 6), {
dynamic: true,
});
const linkMat = glowLines({ color: colors.accent, opacity: 0.28 });
root.add(new THREE.LineSegments(linkGeo, linkMat));
// packet paths are rebuilt in place each frame as layers bob
const linkPaths = links.map(() => [
new THREE.Vector3(),
new THREE.Vector3(),
]);
const packets = createPackets(stage, linkPaths, {
perPath: 1,
speed: 0.5,
trail: 6,
size: 8,
});
root.add(packets.points);
const world = (li: number, v: THREE.Vector3, out: THREE.Vector3) =>
out.copy(v).add(layers[li]!.group.position);
return {
frame: { width: 3.6, height: 4.1 },
update(t, dt) {
const e = stage.entry;
layers.forEach((L, i) => {
const local = stagger(e, i, LAYERS, 0.45);
const bob = stage.reducedMotion
? 0
: Math.sin(t * 0.8 + i * 1.3) * 0.035;
L.group.position.y = layerY(i) + bob + (1 - local) * 1.6;
L.gridMat.uniforms.uOpacity!.value = 0.1 * local;
L.borderMat.uniforms.uOpacity!.value = 0.55 * local;
L.nodeMat.uniforms.uOpacity!.value = smooth(
(local - 0.6) / 0.4,
);
});
const pos = linkGeo.attributes.position as THREE.BufferAttribute;
links.forEach((lk, i) => {
const pa = world(
lk.lower,
layers[lk.lower]!.services[lk.a]!,
linkPaths[i]![0]!,
);
const pb = world(
lk.lower + 1,
layers[lk.lower + 1]!.services[lk.b]!,
linkPaths[i]![1]!,
);
pos.setXYZ(i * 2, pa.x, pa.y, pa.z);
pos.setXYZ(i * 2 + 1, pb.x, pb.y, pb.z);
});
pos.needsUpdate = true;
const linksOn = smooth((e - 0.7) / 0.3);
linkMat.uniforms.uOpacity!.value = 0.28 * linksOn;
if (stage.reducedMotion) return;
packets.update(dt, linksOn);
root.rotation.y =
Math.PI / 4 +
Math.sin(t * 0.15) * 0.15 +
stage.pointer.x * 0.35;
root.rotation.x = 0.62 + stage.pointer.y * 0.12;
},
};
};
export default stack;
+165
View File
@@ -0,0 +1,165 @@
import * as THREE from "three";
import type { SceneFactory } from "../stage";
/**
* Blog: a field of signal — a point grid rippling like a waveform, with
* faint scan rows and crests catching the accent colour. The pointer drops
* a ripple into the field. Entry: the field fades in front-to-back and the
* waves swell up from flat.
*/
const COLS = 110;
const ROWS = 56;
const WIDTH = 6;
const DEPTH = 4;
const vertex = /* glsl */ `
uniform float uTime;
uniform float uAmp;
uniform float uEntry;
uniform vec2 uRipple;
uniform float uPixelRatio;
uniform float uSize;
varying float vAlpha;
varying float vCrest;
float height(vec2 p) {
float h = sin(p.x * 1.3 + uTime * 0.7) * 0.18;
h += sin(p.y * 2.1 - uTime * 0.9 + p.x * 0.4) * 0.12;
h += sin((p.x + p.y) * 3.3 + uTime * 1.4) * 0.05;
float d = distance(p, uRipple);
h += sin(d * 7.0 - uTime * 3.5) * 0.09 * exp(-d * 1.2);
return h;
}
void main() {
vec3 p = position;
float h = height(p.xz) * uAmp;
p.y += h;
vCrest = smoothstep(0.12, 0.32, h);
// fade in from the front row backwards, fade edges/back
float depth01 = (p.z + ${DEPTH.toFixed(1)} * 0.75) / ${DEPTH.toFixed(1)};
float reveal = 1.0 - smoothstep(uEntry * 1.25 - 0.2, uEntry * 1.25, 1.0 - depth01);
float edge = 1.0 - smoothstep(${(WIDTH * 0.2).toFixed(2)}, ${(WIDTH * 0.42).toFixed(2)}, abs(p.x));
vAlpha = reveal * edge * mix(0.12, 1.0, depth01);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = min(uSize * (1.0 + vCrest) * (5.0 / -mv.z), 10.0) * uPixelRatio;
gl_Position = projectionMatrix * mv;
}
`;
const fragment = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uAccent;
uniform float uOpacity;
uniform float uIsPoint;
varying float vAlpha;
varying float vCrest;
void main() {
float a = 1.0;
if (uIsPoint > 0.5) {
float r = length(gl_PointCoord - 0.5);
a = smoothstep(0.5, 0.0, r);
}
vec3 col = mix(uColor, uAccent, vCrest);
gl_FragColor = vec4(col, a * vAlpha * uOpacity * (0.6 + vCrest));
}
`;
const waves: SceneFactory = (stage) => {
const { scene, colors } = stage;
const root = new THREE.Group();
root.rotation.x = 0.42;
root.position.y = -0.25;
scene.add(root);
const pos = new Float32Array(COLS * ROWS * 3);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const i = r * COLS + c;
pos[i * 3] = (c / (COLS - 1) - 0.5) * WIDTH;
pos[i * 3 + 1] = 0;
pos[i * 3 + 2] = (r / (ROWS - 1)) * DEPTH - DEPTH * 0.75;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const uniforms = {
uTime: { value: 0 },
uAmp: { value: 0 },
uEntry: { value: 0 },
uRipple: { value: new THREE.Vector2(0, 0) },
uPixelRatio: { value: stage.pixelRatio },
uSize: { value: 3 },
uColor: { value: colors.line },
uAccent: { value: colors.accent },
};
const common = {
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
};
root.add(
new THREE.Points(
geo,
new THREE.ShaderMaterial({
...common,
uniforms: {
...uniforms,
uOpacity: { value: 0.9 },
uIsPoint: { value: 1 },
},
}),
),
);
// every 4th row as a faint scan line
const index: number[] = [];
for (let r = 0; r < ROWS; r += 4) {
for (let c = 0; c < COLS - 1; c++) {
index.push(r * COLS + c, r * COLS + c + 1);
}
}
const lineGeo = geo.clone();
lineGeo.setIndex(index);
root.add(
new THREE.LineSegments(
lineGeo,
new THREE.ShaderMaterial({
...common,
uniforms: {
...uniforms,
uOpacity: { value: 0.18 },
uIsPoint: { value: 0 },
},
}),
),
);
const ripple = new THREE.Vector2();
return {
frame: { width: 4.6, height: 2.4 },
update(t) {
const e = stage.entry;
uniforms.uTime.value = stage.reducedMotion ? 2 : t;
uniforms.uEntry.value = e;
uniforms.uAmp.value = 0.15 + 0.85 * e * e;
ripple.set(
stage.pointer.x * WIDTH * 0.4,
(stage.pointer.y + 0.2) * DEPTH * 0.5,
);
uniforms.uRipple.value.lerp(ripple, 0.05);
if (!stage.reducedMotion) {
root.rotation.y = stage.pointer.x * 0.12;
root.rotation.x = 0.42 + stage.pointer.y * 0.06;
}
},
};
};
export default waves;
+204
View File
@@ -0,0 +1,204 @@
import * as THREE from "three";
import { gsap } from "gsap";
/**
* # Three.js stage
*
* Shared renderer / camera / loop for every page graphic. A scene plugs in
* by returning a `SceneInstance`; the stage handles framing, resize, pointer
* smoothing, the GSAP-driven entry animation, pausing when off-screen and
* reduced motion.
*/
export type Stage = {
THREE: typeof THREE;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
/** Smoothed pointer, -1 → 1 on both axes (y down). */
pointer: THREE.Vector2;
reducedMotion: boolean;
pixelRatio: number;
/** 0 → 1 entry progress, tweened by GSAP once the scene first shows. */
entry: number;
/** 0 → 1 as the stage's section scrolls out of the viewport. */
scroll: number;
colors: { line: THREE.Color; accent: THREE.Color };
};
export type SceneInstance = {
/** World-space box the camera must keep in frame. */
frame: { width: number; height: number };
update(t: number, dt: number): void;
dispose?(): void;
};
export type SceneFactory = (
stage: Stage,
) => SceneInstance | Promise<SceneInstance>;
export type StageOptions = {
canvas: HTMLCanvasElement;
container: HTMLElement;
factory: SceneFactory;
entryDuration?: number;
entryDelay?: number;
onReady?: () => void;
};
const FOV = 32;
export async function createStage(opts: StageOptions) {
const { canvas, container, factory } = opts;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pixelRatio);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100);
const stage: Stage = {
THREE,
renderer,
scene,
camera,
pointer: new THREE.Vector2(),
reducedMotion,
pixelRatio,
entry: reducedMotion ? 1 : 0,
scroll: 0,
colors: {
line: new THREE.Color("#ededed"),
accent: new THREE.Color("#34d399"),
},
};
const instance = await factory(stage);
// ---- pointer ----
const rawPointer = new THREE.Vector2();
const onPointerMove = (e: PointerEvent) => {
rawPointer.set(
(e.clientX / window.innerWidth) * 2 - 1,
(e.clientY / window.innerHeight) * 2 - 1,
);
};
if (!reducedMotion) {
window.addEventListener("pointermove", onPointerMove, {
passive: true,
});
}
// ---- scroll progress of the hosting section ----
const section = container.closest("section") ?? container;
const readScroll = () => {
const rect = section.getBoundingClientRect();
const p = -rect.top / Math.max(rect.height * 0.85, 1);
stage.scroll = Math.min(Math.max(p, 0), 1);
};
// ---- framing ----
function resize() {
const w = container.clientWidth;
const h = container.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
const tan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
const zForHeight = instance.frame.height / 2 / tan;
const zForWidth = instance.frame.width / 2 / (tan * camera.aspect);
camera.position.set(0, 0, Math.max(zForHeight, zForWidth));
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
if (reducedMotion) renderFrame(0, 0);
}
function renderFrame(t: number, dt: number) {
readScroll();
instance.update(t, dt);
renderer.render(scene, camera);
}
// ---- loop ----
let raf = 0;
let running = false;
let last = 0;
let elapsed = 0;
let entryTween: gsap.core.Tween | undefined;
function loop(now: number) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += dt;
const k = Math.min(dt * 3, 1);
stage.pointer.x += (rawPointer.x - stage.pointer.x) * k;
stage.pointer.y += (rawPointer.y - stage.pointer.y) * k;
renderFrame(elapsed, dt);
raf = requestAnimationFrame(loop);
}
function start() {
if (running || reducedMotion) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(loop);
entryTween ??= gsap.to(stage, {
entry: 1,
duration: opts.entryDuration ?? 2.8,
delay: opts.entryDelay ?? 0.15,
ease: "power2.inOut",
});
}
function stop() {
running = false;
cancelAnimationFrame(raf);
}
const visibility = new IntersectionObserver(([entry]) =>
entry?.isIntersecting ? start() : stop(),
);
visibility.observe(container);
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
const onScrollStatic = () => renderFrame(0, 0);
if (reducedMotion) {
window.addEventListener("scroll", onScrollStatic, { passive: true });
}
resize();
renderFrame(0, 0);
opts.onReady?.();
return {
stage,
dispose() {
stop();
entryTween?.kill();
visibility.disconnect();
resizeObserver.disconnect();
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("scroll", onScrollStatic);
instance.dispose?.();
scene.traverse((obj) => {
const o = obj as THREE.Mesh;
o.geometry?.dispose();
const m = o.material as THREE.Material | THREE.Material[];
(Array.isArray(m) ? m : [m]).forEach((x) => x?.dispose?.());
});
renderer.dispose();
},
};
}
@@ -9,7 +9,10 @@ import { UserRoundSearch } from "lucide-react";
export default function AboutSection() { export default function AboutSection() {
return ( return (
<Section> <Section>
<Stack className="w-full max-w-full xl:max-w-[50vw]"> <Stack
className="w-full max-w-full xl:max-w-[50vw]"
data-reveal-stagger
>
<H2 className="leading-snug"> <H2 className="leading-snug">
I Build & Operate Production Systems I Build & Operate Production Systems
</H2> </H2>
@@ -22,7 +22,7 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
const categories = Object.keys(skills) as (keyof typeof skills)[]; const categories = Object.keys(skills) as (keyof typeof skills)[];
const TitleSection = ( const TitleSection = (
<Row className="w-full justify-between"> <Row className="w-full justify-between" data-reveal="up">
<Stack> <Stack>
{noTitle ? null : ( {noTitle ? null : (
<React.Fragment> <React.Fragment>
@@ -65,7 +65,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
</Span> </Span>
</Stack> </Stack>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{skills[ctgr].portfolio.map( {skills[ctgr].portfolio.map(
(portfolio, index) => { (portfolio, index) => {
return ( return (
@@ -119,7 +122,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) {
})} })}
</Stack> </Stack>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{skills[category].portfolio.map((portfolio, index) => { {skills[category].portfolio.map((portfolio, index) => {
return ( return (
<MySkillsCard <MySkillsCard
@@ -25,7 +25,7 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
); );
const TitleSection = ( const TitleSection = (
<Row className="w-full justify-between"> <Row className="w-full justify-between" data-reveal="up">
<Stack> <Stack>
{!noTitle && ( {!noTitle && (
<React.Fragment> <React.Fragment>
@@ -61,7 +61,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
<Stack className="gap-6"> <Stack className="gap-6">
<H3 className="m-0 text-lg">{ctgr}</H3> <H3 className="m-0 text-lg">{ctgr}</H3>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{work[ctgr].portfolio.map( {work[ctgr].portfolio.map(
(portfolio, index) => { (portfolio, index) => {
return ( return (
@@ -113,7 +116,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
})} })}
</Stack> </Stack>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"> <div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 w-full"
data-reveal-stagger="up"
>
{work[category].portfolio.map((portfolio, index) => { {work[category].portfolio.map((portfolio, index) => {
return ( return (
<MyWorkPortfolioCard <MyWorkPortfolioCard
@@ -9,7 +9,10 @@ import { Contact, Mail } from "lucide-react";
export default function FooterCTASection() { export default function FooterCTASection() {
return ( return (
<Section> <Section>
<Stack className="w-full max-w-full xl:max-w-[50vw] gap-8"> <Stack
className="w-full max-w-full xl:max-w-[50vw] gap-8"
data-reveal-stagger
>
<H2 className="leading-snug">Need Help With Infrastructure?</H2> <H2 className="leading-snug">Need Help With Infrastructure?</H2>
<Span> <Span>
Whether it's CI/CD, cost optimization, or production Whether it's CI/CD, cost optimization, or production
+35 -7
View File
@@ -7,20 +7,44 @@ import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack"; import Stack from "@/src/components/lib/layout/Stack";
import { Contact, ScrollText, Terminal } from "lucide-react"; import { Contact, ScrollText, Terminal } from "lucide-react";
import ResumeButton from "../../general/resume-button"; import ResumeButton from "../../general/resume-button";
import SceneCanvas from "../../lib/three/SceneCanvas";
export default function Main() { export default function Main() {
return ( return (
<Section> <Section
<Stack className="w-full max-w-full xl:max-w-[50vw]"> className={[
<Span>Benjamin Toby</Span> "relative overflow-hidden justify-start xl:justify-center",
<H1 className="leading-snug m-0!"> "min-h-[calc(100svh-var(--header-height))]",
"pt-4! xl:pt-20!",
].join(" ")}
>
<div
className={[
"relative w-full h-[40svh] min-h-[280px] max-h-[520px] mb-4",
"xl:absolute xl:inset-y-0 xl:right-0 xl:left-auto",
"xl:w-1/2 xl:h-auto xl:max-h-none xl:mb-0",
].join(" ")}
>
<SceneCanvas scene="face" className="w-full h-full" />
</div>
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw]">
<Span data-reveal="fade">Benjamin Toby</Span>
<H1 className="leading-snug m-0!" data-reveal="text">
CTO, DevOps & Platform Engineer. CTO, DevOps & Platform Engineer.
</H1> </H1>
<Span className="text-xl"> <Span
className="text-xl"
data-reveal="up"
data-reveal-delay="0.4"
>
I help teams ship faster, cut infrastructure costs, and I help teams ship faster, cut infrastructure costs, and
achieve reliable production systems. achieve reliable production systems.
</Span> </Span>
<Row className="my-4 gap-6"> <Row
className="my-4 gap-6"
data-reveal-stagger="up"
data-reveal-delay="0.6"
>
<Row> <Row>
<LucideIcon name="CheckCircle2" size={20} /> <LucideIcon name="CheckCircle2" size={20} />
<Span>99.99% uptime targets</Span> <Span>99.99% uptime targets</Span>
@@ -34,7 +58,11 @@ export default function Main() {
<Span>Over 80% Operations cost reduction</Span> <Span>Over 80% Operations cost reduction</Span>
</Row> </Row>
</Row> </Row>
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4"> <Row
className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4"
data-reveal-stagger="up"
data-reveal-delay="0.85"
>
<Button <Button
title="Contact Me" title="Contact Me"
beforeIcon={ beforeIcon={
+30 -26
View File
@@ -1,36 +1,38 @@
import Button from "@/src/components/lib/layout/Button"; import Button from "@/src/components/lib/layout/Button";
import Divider from "@/src/components/lib/layout/Divider"; import Divider from "@/src/components/lib/layout/Divider";
import H1 from "@/src/components/lib/layout/H1";
import H2 from "@/src/components/lib/layout/H2"; import H2 from "@/src/components/lib/layout/H2";
import P from "@/src/components/lib/layout/P"; import P from "@/src/components/lib/layout/P";
import Row from "@/src/components/lib/layout/Row"; import Row from "@/src/components/lib/layout/Row";
import Section from "@/src/components/lib/layout/Section"; import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span"; import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack"; import Stack from "@/src/components/lib/layout/Stack";
import { ScrollText, Terminal } from "lucide-react"; import { Terminal } from "lucide-react";
import React from "react";
import PageHero from "../../general/page-hero";
import ResumeButton from "../../general/resume-button"; import ResumeButton from "../../general/resume-button";
export default function Main() { export default function Main() {
return ( return (
<Section> <React.Fragment>
<Stack className="w-full max-w-full xl:max-w-[50vw] gap-8"> <PageHero
{/* <Span className="leading-snug -mb-6 text-sm"> scene="helix"
I Build & Operate Production Systems eyebrow="Background"
</Span> */} title="About Me"
<H1 className="leading-snug">About Me</H1> subtitle="I'm Benjamin Toby, a senior DevOps/Platform engineer and CTO at Summit Lending. I build and operate production systems that support real businesses."
<Span> />
I'm Benjamin Toby, a senior DevOps/Platform engineer and CTO
at Summit Lending. I build and operate production systems
that support real businesses.
</Span>
<Divider /> <Divider />
<Section>
<Stack
className="w-full max-w-full xl:max-w-[50vw] gap-8"
data-reveal-stagger
>
<H2>What I Do</H2> <H2>What I Do</H2>
<Span>I focus on infrastructure and reliability:</Span> <Span>I focus on infrastructure and reliability:</Span>
<ul className="space-y-4"> <ul className="space-y-4">
<li>CI/CD automation and deployment pipelines</li> <li>CI/CD automation and deployment pipelines</li>
<li>Multi-cloud architecture (AWS, GCP, Azure, Hetzner)</li> <li>
Multi-cloud architecture (AWS, GCP, Azure, Hetzner)
</li>
<li>NGINX hardening, TLS, caching, rate limiting</li> <li>NGINX hardening, TLS, caching, rate limiting</li>
<li>Full-stack delivery with Next.js, Bun, and SQL</li> <li>Full-stack delivery with Next.js, Bun, and SQL</li>
</ul> </ul>
@@ -41,23 +43,24 @@ export default function Main() {
<H2>Background</H2> <H2>Background</H2>
<P> <P>
I started as a full-stack developer and moved into DevOps I started as a full-stack developer and moved into
and platform engineering out of necessity, building systems DevOps and platform engineering out of necessity,
that could scale without breaking. That led to Summit building systems that could scale without breaking. That
Lending, where I manage all engineering, from infrastructure led to Summit Lending, where I manage all engineering,
to product. from infrastructure to product.
</P> </P>
<P> <P>
Along the way I built TurboCI, a deployment orchestration Along the way I built TurboCI, a deployment
tool, and Datasquirel, a cloud SQL platform. I write about orchestration tool, and Datasquirel, a cloud SQL
what I learn on my <a href="/blog">blog</a>. platform. I write about what I learn on my{" "}
<a href="/blog">blog</a>.
</P> </P>
<Divider /> <Divider />
<Span> <Span>
I focus on measurable outcomes: faster deployments, lower I focus on measurable outcomes: faster deployments,
costs, fewer incidents. lower costs, fewer incidents.
</Span> </Span>
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4"> <Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
@@ -75,5 +78,6 @@ export default function Main() {
</Row> </Row>
</Stack> </Stack>
</Section> </Section>
</React.Fragment>
); );
} }
@@ -28,7 +28,7 @@ export default function BlogPostsList() {
return ( return (
<Section className="!mt-0 !pt-0"> <Section className="!mt-0 !pt-0">
<Stack className="w-full"> <Stack className="w-full" data-reveal-stagger>
{blogPosts.map((post, index) => { {blogPosts.map((post, index) => {
return <BlogPostsListCard post={post} key={index} />; return <BlogPostsListCard post={post} key={index} />;
})} })}
+7 -17
View File
@@ -1,22 +1,12 @@
import H1 from "@/src/components/lib/layout/H1"; import PageHero from "@/src/components/general/page-hero";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import React from "react";
export default function Hero() { export default function Hero() {
const { pageProps } = React.useContext(AppContext);
return ( return (
<Section> <PageHero
<Stack className="w-full gap-0"> scene="waves"
<H1 className="leading-snug">Tech Musings</H1> eyebrow="Blog"
<Span> title="Tech Musings"
A few takes and tips from my experience and knowledge in the subtitle="A few takes and tips from my experience and knowledge in the tech industry"
tech industry />
</Span>
</Stack>
</Section>
); );
} }
+3 -1
View File
@@ -19,7 +19,9 @@ export default function Main() {
<Stack className="w-full max-w-full xl:max-w-[800px] overflow-auto"> <Stack className="w-full max-w-full xl:max-w-[800px] overflow-auto">
{/* <Divider dashed className="border-[2px] my-6" /> */} {/* <Divider dashed className="border-[2px] my-6" /> */}
<Stack className="gap-1"> <Stack className="gap-1">
<H1 className="leading-snug mb-1">{blogPost?.title}</H1> <H1 className="leading-snug mb-1" data-reveal="text">
{blogPost?.title}
</H1>
<Span>{blogPost?.excerpt}</Span> <Span>{blogPost?.excerpt}</Span>
<Stack className="gap-2 mt-2"> <Stack className="gap-2 mt-2">
<Breadcrumbs <Breadcrumbs
+8 -13
View File
@@ -1,28 +1,23 @@
import H1 from "@/src/components/lib/layout/H1";
import Link from "@/src/components/lib/layout/Link"; import Link from "@/src/components/lib/layout/Link";
import Row from "@/src/components/lib/layout/Row"; import Row from "@/src/components/lib/layout/Row";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span"; import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import PageHero from "../../general/page-hero";
export default function Main() { export default function Main() {
return ( return (
<Section> <PageHero
<Stack className="w-full max-w-full xl:max-w-[50vw]"> scene="globe"
<H1 className="leading-snug">Contact Me</H1> eyebrow="Get in touch"
<Span> title="Contact Me"
Need help with CI/CD, cost optimization, or production subtitle="Need help with CI/CD, cost optimization, or production reliability? Book a call or send a message."
reliability? Book a call or send a message. >
</Span>
<Link href={`mailto:[email protected]`} className="border-none"> <Link href={`mailto:[email protected]`} className="border-none">
<Row className="items-center"> <Row className="items-center">
<Mail size={20} className="mt-1" /> <Mail size={20} className="mt-1" />
<Span className="text-2xl">ben@tben.me</Span> <Span className="text-2xl">ben@tben.me</Span>
</Row> </Row>
</Link> </Link>
</Stack> </PageHero>
</Section>
); );
} }
+7 -10
View File
@@ -1,15 +1,12 @@
import H1 from "@/src/components/lib/layout/H1"; import PageHero from "../../general/page-hero";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
export default function Main() { export default function Main() {
return ( return (
<Section> <PageHero
<Stack className="w-full max-w-full xl:max-w-[50vw]"> scene="sphere"
<H1 className="leading-snug">My Skills</H1> eyebrow="Toolbox"
<Span>A summary of the tools I've mastered over the years</Span> title="My Skills"
</Stack> subtitle="A summary of the tools I've mastered over the years"
</Section> />
); );
} }
+7 -10
View File
@@ -1,15 +1,12 @@
import H1 from "@/src/components/lib/layout/H1"; import PageHero from "../../general/page-hero";
import Section from "@/src/components/lib/layout/Section";
import Span from "@/src/components/lib/layout/Span";
import Stack from "@/src/components/lib/layout/Stack";
export default function Main() { export default function Main() {
return ( return (
<Section> <PageHero
<Stack className="w-full max-w-full xl:max-w-[50vw]"> scene="stack"
<H1 className="leading-snug">My Work</H1> eyebrow="Portfolio"
<Span>Some of My Work</Span> title="My Work"
</Stack> subtitle="Systems I've designed, shipped and kept running in production."
</Section> />
); );
} }
+5 -1
View File
@@ -21,10 +21,14 @@ export default function Aside({}: Props) {
"px-6 py-4 h-[var(--header-height)] border-0", "px-6 py-4 h-[var(--header-height)] border-0",
"border-b border-white/10", "border-b border-white/10",
)} )}
data-reveal="fade"
> >
<Logo size={28} /> <Logo size={28} />
</div> </div>
<Stack className="items-center w-full py-6 mt-auto"> <Stack
className="items-center w-full py-6 mt-auto"
data-reveal-stagger="left"
>
{SocialLinks.map((link, index) => { {SocialLinks.map((link, index) => {
return <SocialLink key={index} link={link} />; return <SocialLink key={index} link={link} />;
})} })}
+5 -2
View File
@@ -21,10 +21,13 @@ export default function Header({ menuOpen, setMenuOpen }: Props) {
"bg-background-dark z-10", "bg-background-dark z-10",
)} )}
> >
<Row className="gap-6 ml-auto hidden md:flex"> <Row className="gap-6 ml-auto hidden md:flex" data-reveal="down">
<LinkList links={HeaderLinks} /> <LinkList links={HeaderLinks} />
</Row> </Row>
<Row className="flex md:hidden w-full justify-between"> <Row
className="flex md:hidden w-full justify-between"
data-reveal="fade"
>
<Logo size={25} /> <Logo size={25} />
<Button <Button
title="Mobile Menu" title="Mobile Menu"
+2
View File
@@ -4,11 +4,13 @@ import Header from "./Header";
import Footer from "./Footer"; import Footer from "./Footer";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import MobileMenu from "./(sections)/MobileMenu"; import MobileMenu from "./(sections)/MobileMenu";
import useMotion from "@/src/components/lib/motion/use-motion";
type Props = PropsWithChildren; type Props = PropsWithChildren;
export default function Layout({ children }: Props) { export default function Layout({ children }: Props) {
const [menuOpen, setMenuOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false);
useMotion();
return ( return (
<div className="flex flex-row items-stretch w-full min-h-screen"> <div className="flex flex-row items-stretch w-full min-h-screen">
+4
View File
@@ -1,13 +1,17 @@
import { SchemaJSON } from "@/src/layouts/main/(data)/schema"; import { SchemaJSON } from "@/src/layouts/main/(data)/schema";
import { MotionHeadScript } from "@/src/components/lib/motion/head-script";
import { BunextPageHeadFCProps } from "@moduletrace/bunext/types"; import { BunextPageHeadFCProps } from "@moduletrace/bunext/types";
export const RootHead = ({}: BunextPageHeadFCProps) => { export const RootHead = ({}: BunextPageHeadFCProps) => {
return ( return (
<>
<script dangerouslySetInnerHTML={{ __html: MotionHeadScript }} />
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: JSON.stringify(SchemaJSON), __html: JSON.stringify(SchemaJSON),
}} }}
></script> ></script>
</>
); );
}; };
+11
View File
@@ -103,3 +103,14 @@
.markdown-rendered hr { .markdown-rendered hr {
@apply opacity-20 my-6; @apply opacity-20 my-6;
} }
/* ---- Motion system (see src/components/lib/motion/motion.ts) ---- */
html.motion:not(.motion-fallback) [data-reveal],
html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
visibility: hidden;
}
.motion-line {
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}