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:
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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){}})();`;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -1,79 +1,83 @@
|
|||||||
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
|
<Divider />
|
||||||
at Summit Lending. I build and operate production systems
|
<Section>
|
||||||
that support real businesses.
|
<Stack
|
||||||
</Span>
|
className="w-full max-w-full xl:max-w-[50vw] gap-8"
|
||||||
|
data-reveal-stagger
|
||||||
|
>
|
||||||
|
<H2>What I Do</H2>
|
||||||
|
<Span>I focus on infrastructure and reliability:</Span>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
<li>CI/CD automation and deployment pipelines</li>
|
||||||
|
<li>
|
||||||
|
Multi-cloud architecture (AWS, GCP, Azure, Hetzner)
|
||||||
|
</li>
|
||||||
|
<li>NGINX hardening, TLS, caching, rate limiting</li>
|
||||||
|
<li>Full-stack delivery with Next.js, Bun, and SQL</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<Divider />
|
<ResumeButton />
|
||||||
|
|
||||||
<H2>What I Do</H2>
|
<Divider />
|
||||||
<Span>I focus on infrastructure and reliability:</Span>
|
|
||||||
<ul className="space-y-4">
|
|
||||||
<li>CI/CD automation and deployment pipelines</li>
|
|
||||||
<li>Multi-cloud architecture (AWS, GCP, Azure, Hetzner)</li>
|
|
||||||
<li>NGINX hardening, TLS, caching, rate limiting</li>
|
|
||||||
<li>Full-stack delivery with Next.js, Bun, and SQL</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ResumeButton />
|
<H2>Background</H2>
|
||||||
|
<P>
|
||||||
|
I started as a full-stack developer and moved into
|
||||||
|
DevOps and platform engineering out of necessity,
|
||||||
|
building systems that could scale without breaking. That
|
||||||
|
led to Summit Lending, where I manage all engineering,
|
||||||
|
from infrastructure to product.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
Along the way I built TurboCI, a deployment
|
||||||
|
orchestration tool, and Datasquirel, a cloud SQL
|
||||||
|
platform. I write about what I learn on my{" "}
|
||||||
|
<a href="/blog">blog</a>.
|
||||||
|
</P>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<H2>Background</H2>
|
<Span>
|
||||||
<P>
|
I focus on measurable outcomes: faster deployments,
|
||||||
I started as a full-stack developer and moved into DevOps
|
lower costs, fewer incidents.
|
||||||
and platform engineering out of necessity, building systems
|
</Span>
|
||||||
that could scale without breaking. That led to Summit
|
|
||||||
Lending, where I manage all engineering, from infrastructure
|
|
||||||
to product.
|
|
||||||
</P>
|
|
||||||
<P>
|
|
||||||
Along the way I built TurboCI, a deployment orchestration
|
|
||||||
tool, and Datasquirel, a cloud SQL platform. I write about
|
|
||||||
what I learn on my <a href="/blog">blog</a>.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<Divider />
|
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
|
||||||
|
<Button
|
||||||
<Span>
|
title="View My Work"
|
||||||
I focus on measurable outcomes: faster deployments, lower
|
beforeIcon={
|
||||||
costs, fewer incidents.
|
<Terminal size={17} className="font-normal" />
|
||||||
</Span>
|
}
|
||||||
|
href="/work"
|
||||||
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
|
className="grow w-full bg-transparent!"
|
||||||
<Button
|
variant="outlined"
|
||||||
title="View My Work"
|
>
|
||||||
beforeIcon={
|
View My Work
|
||||||
<Terminal size={17} className="font-normal" />
|
</Button>
|
||||||
}
|
</Row>
|
||||||
href="/work"
|
</Stack>
|
||||||
className="grow w-full bg-transparent!"
|
</Section>
|
||||||
variant="outlined"
|
</React.Fragment>
|
||||||
>
|
|
||||||
View My Work
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
</Stack>
|
|
||||||
</Section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<Row className="items-center">
|
||||||
<Link href={`mailto:[email protected]`} className="border-none">
|
<Mail size={20} className="mt-1" />
|
||||||
<Row className="items-center">
|
<Span className="text-2xl">ben@tben.me</Span>
|
||||||
<Mail size={20} className="mt-1" />
|
</Row>
|
||||||
<Span className="text-2xl">ben@tben.me</Span>
|
</Link>
|
||||||
</Row>
|
</PageHero>
|
||||||
</Link>
|
|
||||||
</Stack>
|
|
||||||
</Section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
<>
|
||||||
type="application/ld+json"
|
<script dangerouslySetInnerHTML={{ __html: MotionHeadScript }} />
|
||||||
dangerouslySetInnerHTML={{
|
<script
|
||||||
__html: JSON.stringify(SchemaJSON),
|
type="application/ld+json"
|
||||||
}}
|
dangerouslySetInnerHTML={{
|
||||||
></script>
|
__html: JSON.stringify(SchemaJSON),
|
||||||
|
}}
|
||||||
|
></script>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user