From d93550ed299a23e1c0d6c180afe86b6b49995557 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Tue, 3 Nov 2026 11:57:05 +0100 Subject: [PATCH] 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 --- bun.lock | 21 + package.json | 5 +- public/data/face-mesh.json | 1 + src/components/general/page-hero.tsx | 85 ++++ src/components/lib/layout/Divider.tsx | 3 +- src/components/lib/motion/head-script.ts | 11 + src/components/lib/motion/motion.ts | 279 +++++++++++ src/components/lib/motion/use-motion.tsx | 16 + src/components/lib/three/SceneCanvas.tsx | 78 ++++ src/components/lib/three/materials.ts | 226 +++++++++ src/components/lib/three/scenes/face.ts | 435 ++++++++++++++++++ src/components/lib/three/scenes/globe.ts | 260 +++++++++++ src/components/lib/three/scenes/helix.ts | 190 ++++++++ src/components/lib/three/scenes/sphere.ts | 206 +++++++++ src/components/lib/three/scenes/stack.ts | 183 ++++++++ src/components/lib/three/scenes/waves.ts | 165 +++++++ src/components/lib/three/stage.ts | 204 ++++++++ .../pages/Home/(sections)/AboutSection.tsx | 5 +- .../pages/Home/(sections)/MySkillsSection.tsx | 12 +- .../pages/Home/(sections)/MyWorkSection.tsx | 12 +- .../Home/(sections)/footer-cta-section.tsx | 5 +- src/components/pages/Home/index.tsx | 42 +- src/components/pages/about/index.tsx | 122 ++--- .../pages/blog/(sections)/BlogPostsList.tsx | 2 +- src/components/pages/blog/(sections)/Hero.tsx | 24 +- src/components/pages/blog/slug/index.tsx | 4 +- src/components/pages/contact/index.tsx | 33 +- src/components/pages/skills/index.tsx | 17 +- src/components/pages/work/index.tsx | 17 +- src/layouts/main/Aside.tsx | 6 +- src/layouts/main/Header.tsx | 7 +- src/layouts/main/index.tsx | 2 + src/pages/(partials)/root-head.tsx | 16 +- src/styles/globals.css | 11 + 34 files changed, 2562 insertions(+), 143 deletions(-) create mode 100644 public/data/face-mesh.json create mode 100644 src/components/general/page-hero.tsx create mode 100644 src/components/lib/motion/head-script.ts create mode 100644 src/components/lib/motion/motion.ts create mode 100644 src/components/lib/motion/use-motion.tsx create mode 100644 src/components/lib/three/SceneCanvas.tsx create mode 100644 src/components/lib/three/materials.ts create mode 100644 src/components/lib/three/scenes/face.ts create mode 100644 src/components/lib/three/scenes/globe.ts create mode 100644 src/components/lib/three/scenes/helix.ts create mode 100644 src/components/lib/three/scenes/sphere.ts create mode 100644 src/components/lib/three/scenes/stack.ts create mode 100644 src/components/lib/three/scenes/waves.ts create mode 100644 src/components/lib/three/stage.ts diff --git a/bun.lock b/bun.lock index 95e6f62..264385b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@moduletrace/bun-mariadb": "^1.1.1", "@moduletrace/bunext": "^1.2.4", "gray-matter": "^4.0.3", + "gsap": "^3.15.0", "html-to-react": "^1.7.0", "lodash": "^4.17.21", "lucide-react": "^0.462.0", @@ -22,6 +23,7 @@ "remark-gfm": "^4.0.1", "showdown": "^2.1.0", "tailwind-merge": "^2.5.5", + "three": "^0.187.0", }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", @@ -32,6 +34,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/showdown": "^2.0.6", + "@types/three": "^0.187.0", "postcss": "^8", "tailwindcss": "^4.1.11", "typescript": "^5", @@ -45,6 +48,8 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], @@ -209,6 +214,8 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "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/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -241,8 +248,14 @@ "@types/showdown": ["@types/showdown@2.0.6", "", {}, "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ=="], + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.187.0", "", { "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/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -367,6 +380,8 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "find-up": ["find-up@1.1.2", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -379,6 +394,8 @@ "gray-matter": ["gray-matter@4.0.3", "", { "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": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "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": ["meow@3.6.0", "", { "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": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "micromark": ["micromark@4.0.2", "", { "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": ["micromark-core-commonmark@2.0.3", "", { "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": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "three": ["three@0.187.0", "", {}, "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-newlines": ["trim-newlines@1.0.0", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="], diff --git a/package.json b/package.json index 9e96747..fad8846 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@moduletrace/bun-mariadb": "^1.1.1", "@moduletrace/bunext": "^1.2.4", "gray-matter": "^4.0.3", + "gsap": "^3.15.0", "html-to-react": "^1.7.0", "lodash": "^4.17.21", "lucide-react": "^0.462.0", @@ -27,7 +28,8 @@ "rehype-prism-plus": "^2.0.1", "remark-gfm": "^4.0.1", "showdown": "^2.1.0", - "tailwind-merge": "^2.5.5" + "tailwind-merge": "^2.5.5", + "three": "^0.187.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", @@ -38,6 +40,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/showdown": "^2.0.6", + "@types/three": "^0.187.0", "postcss": "^8", "tailwindcss": "^4.1.11", "typescript": "^5" diff --git a/public/data/face-mesh.json b/public/data/face-mesh.json new file mode 100644 index 0000000..a30b61e --- /dev/null +++ b/public/data/face-mesh.json @@ -0,0 +1 @@ +{"v":[0.011,-0.3409,0.1789,0.0162,-0.0977,0.4255,0.0097,-0.168,0.2136,-0.0531,0.1551,0.3497,0.0181,-0.0206,0.4597,0.0171,0.081,0.4392,0.0136,0.3259,0.2566,-0.5244,0.3768,-0.0328,0.0147,0.5321,0.2247,0.0184,0.6406,0.2546,0.0247,1.0082,0.2292,0.0095,-0.3746,0.1641,0.0078,-0.4097,0.1381,0.0042,-0.4352,0.0993,0.0093,-0.436,0.0912,0.0081,-0.4817,0.0995,0.0068,-0.5377,0.1158,0.0028,-0.5937,0.1048,-0.0008,-0.6689,0.0309,0.0139,-0.1378,0.3813,-0.0699,-0.1307,0.2743,-0.8063,0.6913,-0.3449,-0.3211,0.3073,0.0326,-0.3967,0.3047,0.0287,-0.4695,0.3107,0.01,-0.5602,0.3518,-0.0497,-0.2542,0.3195,0.0232,-0.4349,0.5303,0.0851,-0.3462,0.5162,0.0816,-0.5182,0.5199,0.059,-0.5716,0.4902,0.0253,-0.6261,0.3106,-0.0946,-0.3064,-0.7697,-0.0926,-0.5517,0.3946,-0.0564,-0.8391,0.3584,-0.4289,-0.7105,0.3675,-0.1638,-0.4069,0.0006,0.0675,-0.1112,-0.3166,0.1756,-0.1073,-0.4015,0.1281,-0.2305,-0.3286,0.1247,-0.3103,-0.3403,0.0513,-0.2041,-0.3911,0.0915,-0.2787,-0.3831,0.0256,-0.4129,-0.4515,-0.0991,-0.0535,-0.0942,0.4209,-0.0604,-0.0179,0.4526,-0.6802,0.5763,0.0186,-0.2313,0.1778,0.0954,-0.2671,-0.0515,0.2361,-0.2728,-0.0101,0.2157,-0.6368,0.0253,-0.0313,-0.0573,0.0764,0.4171,-0.5037,0.6566,0.1554,-0.6094,0.6322,0.095,-0.7311,0.8237,-0.1758,-0.1563,0.5667,0.2091,-0.2697,0.4784,0.054,-0.4776,-0.3587,-0.1035,-0.7886,-0.3643,-0.7519,-0.2102,-0.0972,0.1979,-0.1375,-0.1261,0.1973,-0.418,-0.3576,-0.106,-0.3973,-0.3629,-0.0873,-0.6503,0.6878,0.0669,-0.2762,-0.0811,0.1879,-0.3554,0.6408,0.1962,-0.3672,0.7086,0.2232,-0.4209,0.9859,0.1377,-0.6896,0.7514,-0.0374,-0.3868,0.8436,0.185,-0.7234,0.6143,-0.0401,-0.7649,0.6444,-0.1836,-0.1117,-0.3604,0.1592,-0.217,-0.3609,0.1095,-0.2947,-0.3601,0.0435,-0.1906,-0.113,0.1787,-0.4075,-0.3601,-0.0963,-0.3576,-0.3993,-0.0515,-0.3848,-0.3673,-0.0852,-0.1584,-0.0783,0.3123,-0.2654,-0.3991,0.0101,-0.1909,-0.4115,0.0558,-0.1003,-0.4259,0.0906,-0.1271,-0.6582,0.0253,-0.1224,-0.5795,0.0983,-0.1152,-0.523,0.108,-0.1054,-0.4686,0.09,-0.0949,-0.4255,0.0819,-0.2621,-0.399,0.0014,-0.2802,-0.4169,0.0107,-0.3004,-0.443,0.019,-0.3148,-0.4714,0.0012,-0.371,-0.2566,0.0397,-0.8516,0.0225,-0.8032,0.0115,-0.1563,0.2732,-0.3185,-0.3853,-0.0516,-0.3383,-0.3931,-0.0478,-0.1112,-0.1564,0.1957,-0.2503,-0.1246,0.1216,-0.1238,-0.1428,0.1989,-0.3191,0.1402,0.0644,-0.4494,0.1003,0.0389,-0.2903,-0.042,0.1667,-0.609,0.924,-0.0071,-0.5696,0.8207,0.0826,-0.53,0.7227,0.1537,-0.3376,-0.5308,-0.058,-0.18,0.6742,0.2534,-0.1941,0.8368,0.2374,-0.2145,1.0101,0.2174,-0.5264,0.3249,-0.021,-0.7044,0.2641,-0.1436,-0.2102,0.3365,0.0152,-0.6424,0.4613,-0.0421,-0.1713,0.2211,0.1285,-0.2075,-0.0307,0.3156,-0.7865,0.2131,-0.2379,-0.6581,0.2118,-0.0821,-0.5668,0.1815,-0.0173,-0.423,0.1841,0.0155,-0.3139,0.2077,0.034,-0.23,0.2364,0.0574,-0.0614,0.3101,0.2256,-0.7881,0.0588,-0.2388,-0.6989,0.477,-0.0733,-0.0282,-0.1335,0.3784,-0.2336,0.1038,0.1222,-0.8606,0.369,-0.6851,-0.1657,0.2736,0.0749,-0.2991,-0.0447,0.0842,-0.5942,0.3968,-0.0725,-0.2114,0.0198,0.2905,-0.8321,-0.1658,-0.7982,-0.2094,0.3604,0.001,-0.1333,0.056,0.3696,-0.6354,-0.5527,-0.3721,-0.6239,-0.6453,-0.5206,-0.8431,0.0355,-0.5186,-0.7293,-0.4441,-0.4499,-0.8037,0.5121,-0.3261,-0.3173,-0.8568,-0.1465,-0.0214,-0.1531,0.2688,-0.3104,0.0532,0.0884,-0.7699,0.3577,-0.2416,-0.4581,0.3531,0.0091,-0.3967,0.3464,0.0256,-0.3719,-0.4125,-0.0661,-0.7861,-0.0976,-0.2897,-0.1728,-0.9733,-0.1776,-0.4085,-0.8448,-0.3273,-0.5075,-0.761,-0.4151,0.0215,0.826,0.249,-0.0103,-0.9899,-0.1591,-0.3337,0.3489,0.0276,-0.2722,0.3545,0.016,-0.2297,0.3561,0.0011,-0.7498,0.496,-0.147,-0.2835,0.4087,0.037,-0.3507,0.4293,0.0548,-0.412,0.4356,0.0534,-0.4721,0.4294,0.0338,-0.5104,0.4168,0.0079,-0.844,0.5458,-0.5296,-0.4972,0.3647,-0.0131,0.0085,-0.2248,0.1848,-0.2838,-0.2273,0.1001,-0.2068,-0.0855,0.2311,-0.1173,-0.2197,0.1797,0.0128,0.4255,0.212,-0.528,-0.6675,-0.3109,-0.4275,-0.7614,-0.2348,-0.1756,-0.9172,-0.0725,-0.7144,-0.5234,-0.6461,-0.2326,0.38,0.0161,-0.1166,0.194,0.2362,-0.0075,-0.9391,-0.0585,-0.3012,-0.9194,-0.2358,-0.8274,-0.1357,-0.5338,-0.1854,-0.4134,0.0468,-0.2027,-0.4437,0.0566,-0.219,-0.4862,0.0673,-0.231,-0.5363,0.053,-0.2474,-0.6058,-0.0101,-0.3399,-0.372,-0.0352,-0.3569,-0.3608,-0.0348,-0.3706,-0.3493,-0.0309,-0.4415,-0.2988,-0.0319,-0.7042,-0.1391,-0.138,-0.1144,0.2702,0.1721,-0.1551,0.4242,0.0601,-0.2014,0.4115,0.0323,-0.3225,-0.3848,-0.0442,-0.7134,-0.3273,-0.2859,-0.0843,0.423,0.1615,-0.2781,-0.682,-0.0537,0.016,0.1638,0.3744,-0.0576,0.2284,0.2958,0.0134,0.24,0.3129,-0.1834,0.0827,0.2252,-0.0063,-0.8576,-0.0034,-0.003,-0.7573,0.0145,-0.1456,-0.7399,-0.0018,-0.4599,-0.4934,-0.1403,-0.362,-0.0977,0.0553,-0.3768,-0.5999,-0.1047,-0.5387,-0.0806,0.0243,-0.4376,-0.1663,0.0215,-0.6104,-0.1965,-0.0599,-0.1675,-0.837,-0.021,-0.2415,0.0465,0.1643,-0.5129,-0.5677,-0.215,-0.4095,-0.6759,-0.1672,-0.5318,-0.3833,-0.1312,-0.7732,-0.2323,-0.3388,-0.6178,-0.4087,-0.2028,-0.7957,-0.298,-0.5278,-0.5096,-0.2472,-0.0348,-0.173,0.1495,0.1747,-0.1818,-0.0653,0.3303,-0.2426,-0.0778,0.2413,-0.136,-0.0207,0.3875,-0.2186,0.503,0.0928,-0.3495,0.5611,0.1126,-0.462,0.5771,0.1095,-0.5552,0.5631,0.0768,-0.6178,0.5262,0.0264,-0.6446,0.3844,-0.1057,-0.8484,0.1976,-0.477,-0.5862,0.2782,-0.0594,-0.5119,0.254,-0.0182,-0.4119,0.249,0.0103,-0.315,0.2601,0.0251,-0.2369,0.2806,0.0291,-0.1838,0.3016,0.0367,-0.8582,0.199,-0.7686,-0.2443,-0.0961,0.1993,-0.1178,0.1254,0.292,-0.1148,-0.0769,0.387,-0.0723,-0.1172,0.3485,-0.1194,-0.0866,0.3478,-0.2237,-0.1195,0.1679,-0.0566,-0.1268,0.3676,-0.0497,-0.1429,0.2709,-0.1828,0.3587,0.0117,-0.1427,0.343,0.054,-0.1183,0.3303,0.1041,-0.5336,0.4052,-0.0201,-0.6015,0.4513,-0.0228,0.0815,0.1516,0.3434,0.5294,0.3406,-0.0771,0.0914,-0.1347,0.269,0.7846,0.6405,-0.4098,0.3271,0.2847,0.0044,0.4003,0.2772,-0.0054,0.4721,0.28,-0.0293,0.56,0.3145,-0.096,0.259,0.302,0.0002,0.4456,0.4975,0.0496,0.3578,0.4882,0.0527,0.5261,0.4826,0.0176,0.577,0.4502,-0.0194,0.6217,0.2723,-0.1445,0.2788,-0.778,-0.1123,0.5545,0.3571,-0.1026,0.8027,0.3093,-0.4979,0.702,0.3215,-0.2207,0.4067,-0.0202,0.0393,0.1293,-0.3173,0.1676,0.1156,-0.4021,0.121,0.2379,-0.329,0.1074,0.3119,-0.3408,0.0272,0.2058,-0.3935,0.0769,0.2772,-0.3831,0.0052,0.3957,-0.4644,-0.1292,0.0828,-0.0969,0.4169,0.0944,-0.023,0.4481,0.6869,0.5291,-0.0349,0.238,0.1649,0.0768,0.2821,-0.0627,0.2173,0.2843,-0.0218,0.1966,0.6251,-0.0069,-0.0776,0.0881,0.074,0.4106,0.5234,0.618,0.118,0.6233,0.5871,0.0492,0.7305,0.7749,-0.2341,0.1828,0.5541,0.196,0.2815,0.4574,0.0301,0.4638,-0.3713,-0.139,0.7168,-0.3963,-0.8122,0.2221,-0.1053,0.1861,0.1529,-0.1306,0.1912,0.41,-0.3602,-0.1384,0.3909,-0.366,-0.1164,0.6631,0.6389,0.0182,0.2876,-0.0912,0.1698,0.3808,0.6126,0.1707,0.3961,0.679,0.1969,0.4574,0.9549,0.1043,0.6948,0.7051,-0.0908,0.4213,0.8148,0.1543,0.7253,0.5635,-0.0945,0.7547,0.5954,-0.245,0.1257,-0.3622,0.1511,0.2226,-0.3619,0.0934,0.2956,-0.3618,0.0218,0.2042,-0.1189,0.1678,0.4029,-0.363,-0.1267,0.3539,-0.4059,-0.0783,0.3804,-0.3708,-0.1137,0.1796,-0.0852,0.2995,0.2607,-0.3995,-0.0094,0.1909,-0.4136,0.0418,0.1048,-0.428,0.083,0.1168,-0.6607,0.0179,0.123,-0.5834,0.0897,0.1216,-0.5276,0.0988,0.1164,-0.4704,0.0811,0.1063,-0.4303,0.0732,0.2621,-0.4035,-0.0196,0.2795,-0.422,-0.0102,0.2974,-0.4506,-0.003,0.3106,-0.4792,-0.0225,0.3694,-0.269,0.012,0.7917,-0.0239,-0.8693,0.3161,-0.3893,-0.0776,0.3338,-0.3975,-0.0735,0.1276,-0.1603,0.189,0.2583,-0.1335,0.1071,0.1393,-0.1481,0.1928,0.3224,0.1247,0.0394,0.4486,0.0763,0.0061,0.2991,-0.0527,0.1469,0.6289,0.8815,-0.0549,0.5916,0.781,0.0395,0.5516,0.6808,0.1139,0.3203,-0.5421,-0.0837,0.2116,0.6588,0.2396,0.2355,0.8216,0.2221,0.261,0.9943,0.1999,0.5284,0.2921,-0.0645,0.6941,0.2208,-0.1999,0.2177,0.3205,-0.0051,0.6436,0.4175,-0.0937,0.1807,0.2112,0.1136,0.228,-0.0411,0.3006,0.7642,0.1694,-0.2978,0.6507,0.1742,-0.1345,0.5631,0.1499,-0.0621,0.4253,0.1604,-0.0186,0.3184,0.1887,0.0078,0.235,0.2229,0.0381,0.084,0.3063,0.2194,0.7621,0.0171,-0.3008,0.6958,0.4301,-0.1294,0.0535,-0.1343,0.375,0.2428,0.0921,0.1037,0.8097,0.32,-0.7538,0.1758,0.2635,0.0591,0.3039,-0.0558,0.0638,0.5941,0.356,-0.1213,0.2302,0.0096,0.2753,0.7683,-0.2071,-0.8613,0.2152,0.3446,-0.0203,0.1587,0.0484,0.3585,0.5819,-0.5736,-0.4156,0.5561,-0.663,-0.5646,0.8024,-0.0081,-0.5867,0.6699,-0.4702,-0.5014,0.7803,0.4628,-0.3915,0.2859,-0.8638,-0.1672,0.044,-0.1531,0.2668,0.3152,0.0387,0.0659,0.753,0.3088,-0.3008,0.4626,0.3234,-0.0299,0.4035,0.3197,-0.0085,0.3678,-0.4162,-0.0941,0.7536,-0.1355,-0.3487,0.1441,-0.9765,-0.1883,0.3598,-0.8563,-0.3564,0.4494,-0.7739,-0.4511,0.3384,0.3251,-0.0014,0.2778,0.3337,-0.0088,0.2369,0.3396,-0.0213,0.7429,0.4458,-0.2064,0.2878,0.3865,0.0119,0.3547,0.402,0.0253,0.4177,0.4046,0.0192,0.4753,0.3959,-0.0057,0.5147,0.3805,-0.0353,0.8074,0.4971,-0.5974,0.5019,0.3309,-0.0557,0.2891,-0.2366,0.0795,0.2207,-0.0941,0.2181,0.132,-0.2235,0.1714,0.4786,-0.6804,-0.3472,0.3865,-0.7731,-0.2635,0.1509,-0.9219,-0.0829,0.6411,-0.548,-0.6973,0.2387,0.3634,-0.0067,0.1356,0.1865,0.226,0.2628,-0.9271,-0.2566,0.7799,-0.1771,-0.5978,0.1927,-0.4171,0.0314,0.2067,-0.4504,0.0401,0.2212,-0.495,0.0501,0.2283,-0.5432,0.0346,0.2331,-0.6143,-0.0282,0.3362,-0.3752,-0.0598,0.352,-0.3618,-0.0619,0.3665,-0.3517,-0.06,0.4347,-0.315,-0.0663,0.6798,-0.1726,-0.1875,0.1299,0.2635,0.1617,0.1694,0.4119,0.046,0.211,0.396,0.0117,0.3154,-0.3861,-0.0692,0.6706,-0.3571,-0.3356,0.1026,0.4158,0.1519,0.2556,-0.6897,-0.0719,0.0832,0.2242,0.2901,0.1991,0.0744,0.2101,0.1303,-0.7459,-0.0091,0.4349,-0.5074,-0.174,0.3627,-0.1126,0.0307,0.3512,-0.6087,-0.131,0.5312,-0.1082,-0.0116,0.435,-0.1855,-0.0096,0.5916,-0.2239,-0.1019,0.1482,-0.842,-0.0309,0.2515,0.0356,0.147,0.4738,-0.5824,-0.2508,0.3779,-0.6858,-0.1941,0.5079,-0.3987,-0.1704,0.7307,-0.2661,-0.3948,0.5823,-0.4314,-0.2455,0.7403,-0.3323,-0.5886,0.4968,-0.2688,-0.0707,0.1869,0.1406,0.1599,0.2013,-0.0728,0.3165,0.2557,-0.0886,0.2256,0.164,-0.0276,0.3787,0.2372,0.4856,0.0737,0.3681,0.5348,0.0856,0.4779,0.5415,0.0737,0.5666,0.5227,0.0337,0.6237,0.4836,-0.0221,0.6407,0.3408,-0.1573,0.8076,0.1503,-0.5474,0.584,0.2416,-0.1069,0.5105,0.2236,-0.0592,0.4138,0.2231,-0.0237,0.3184,0.2398,-0.0022,0.2426,0.2646,0.008,0.1892,0.2886,0.0192,0.8018,0.1512,-0.8377,0.2562,-0.1047,0.1844,0.1407,0.1199,0.2809,0.1409,-0.0818,0.3788,0.0954,-0.1203,0.3432,0.1414,-0.0931,0.3391,0.2353,-0.1275,0.1549,0.0798,-0.1286,0.3638,0.0716,-0.1472,0.2676,0.1898,0.3453,-0.0069,0.1526,0.3321,0.0392,0.1325,0.321,0.0936,0.5375,0.3678,-0.0649,0.6059,0.4095,-0.0698],"e":[0,11,0,37,0,164,0,267,1,4,1,19,1,44,1,274,2,94,2,97,2,141,2,164,2,167,2,326,2,370,2,393,3,51,3,195,3,196,3,197,3,236,4,5,4,44,4,45,4,51,4,274,4,275,4,281,5,51,5,195,5,281,6,122,6,168,6,196,6,197,6,351,6,419,7,25,7,33,7,110,7,163,8,9,8,55,8,168,8,193,8,285,8,417,9,55,9,107,9,108,9,151,9,285,9,336,9,337,10,109,10,151,10,338,11,12,11,37,11,72,11,267,11,302,12,13,12,38,12,72,12,268,12,302,13,38,13,82,13,268,13,312,14,15,14,86,14,87,14,316,14,317,15,16,15,85,15,86,15,315,15,316,16,17,16,85,16,315,17,18,17,83,17,84,17,85,17,313,17,314,17,315,18,83,18,200,18,201,18,313,18,421,19,44,19,94,19,125,19,141,19,274,19,354,19,370,20,60,20,79,20,99,20,166,20,238,20,242,21,54,21,68,21,71,21,162,22,23,22,26,22,145,22,153,22,154,22,230,22,231,23,24,23,144,23,145,23,229,23,230,24,110,24,144,24,228,24,229,25,31,25,33,25,110,25,130,25,226,25,228,26,112,26,154,26,155,26,231,26,232,27,28,27,29,27,159,27,160,27,222,27,223,28,56,28,157,28,158,28,159,28,221,28,222,29,30,29,160,29,223,29,224,30,160,30,161,30,224,30,225,30,247,31,111,31,117,31,226,31,228,32,140,32,171,32,194,32,201,32,208,32,211,33,130,33,246,33,247,34,127,34,139,34,143,34,156,34,227,34,234,35,111,35,113,35,124,35,143,35,226,36,100,36,101,36,142,36,203,36,205,36,206,37,39,37,72,37,164,37,167,38,41,38,72,38,81,38,82,39,40,39,72,39,73,39,92,39,165,39,167,40,73,40,74,40,92,40,185,40,186,41,42,41,72,41,73,41,74,41,81,42,74,42,80,42,81,42,183,42,184,43,57,43,61,43,91,43,106,43,146,43,202,43,204,44,45,44,125,44,220,44,237,45,51,45,134,45,220,46,53,46,63,46,70,46,113,46,124,46,156,46,225,47,100,47,114,47,121,47,126,47,128,47,217,48,49,48,64,48,115,48,131,48,219,48,235,49,64,49,102,49,129,49,131,49,209,50,101,50,117,50,118,50,123,50,187,50,205,51,134,51,195,51,236,52,53,52,63,52,65,52,66,52,105,52,222,52,223,53,63,53,223,53,224,53,225,54,68,54,103,54,104,55,65,55,107,55,189,55,193,55,221,56,157,56,173,56,190,56,221,57,61,57,185,57,186,57,202,57,212,58,132,58,172,58,177,58,215,59,75,59,166,59,219,59,235,60,75,60,99,60,166,60,240,61,76,61,146,61,184,61,185,62,76,62,77,62,78,62,96,62,183,62,191,63,68,63,70,63,71,63,104,63,105,64,98,64,102,64,129,64,235,64,240,65,66,65,107,65,221,65,222,66,69,66,105,66,107,67,69,67,103,67,104,67,108,67,109,68,71,68,104,69,104,69,105,69,107,69,108,70,71,70,139,70,156,71,139,71,162,72,73,73,74,74,184,74,185,75,166,75,235,75,240,76,77,76,146,76,183,76,184,77,90,77,91,77,96,77,146,78,95,78,96,78,191,79,166,79,218,79,237,79,238,79,239,80,81,80,183,80,191,81,82,83,84,83,181,83,182,83,201,84,85,84,180,84,181,85,86,85,179,85,180,86,87,86,178,86,179,87,178,88,89,88,95,88,96,88,178,88,179,89,90,89,96,89,179,89,180,90,91,90,96,90,180,90,181,91,106,91,146,91,181,91,182,92,165,92,186,92,206,92,216,93,132,93,137,93,227,93,234,94,141,94,370,95,96,97,98,97,99,97,141,97,165,97,167,97,242,98,99,98,129,98,165,98,203,98,240,99,240,99,242,100,101,100,120,100,121,100,126,100,142,101,118,101,119,101,120,101,205,102,129,103,104,104,105,106,182,106,194,106,204,107,108,108,109,108,151,109,151,110,144,110,163,110,228,111,116,111,117,111,123,111,143,111,226,112,133,112,155,112,232,112,233,112,243,112,244,113,124,113,225,113,226,113,247,114,128,114,174,114,188,114,217,115,131,115,218,115,219,115,220,116,123,116,137,116,143,116,227,117,118,117,123,117,228,117,229,118,119,118,229,118,230,119,120,119,230,120,121,120,230,120,231,120,232,121,128,121,232,122,168,122,188,122,193,122,196,122,245,123,137,123,147,123,177,123,187,124,143,124,156,125,141,125,237,125,241,126,129,126,142,126,209,126,217,127,139,127,162,127,234,128,188,128,232,128,233,128,245,129,142,129,203,129,209,130,226,130,247,131,134,131,198,131,209,131,220,132,137,132,177,133,155,133,173,133,190,133,243,134,198,134,220,134,236,135,136,135,138,135,150,135,169,135,192,135,214,136,138,136,150,136,172,137,177,137,227,138,172,138,192,138,213,138,215,139,156,139,162,140,148,140,170,140,171,140,176,140,211,141,241,141,242,142,203,143,156,143,227,144,145,144,163,145,153,147,177,147,187,147,213,147,215,148,152,148,171,148,175,148,176,149,150,149,170,149,176,150,169,150,170,151,337,151,338,152,175,152,377,153,154,154,155,157,158,157,173,158,159,159,160,160,161,161,246,161,247,164,167,164,267,164,393,165,167,165,203,165,206,166,218,166,219,168,193,168,351,168,417,169,170,169,210,169,211,169,214,170,176,170,211,171,175,171,199,171,208,172,215,173,190,174,188,174,196,174,217,174,236,175,199,175,377,175,396,177,215,178,179,179,180,180,181,181,182,182,194,182,201,183,184,183,191,184,185,185,186,186,212,186,216,187,192,187,205,187,207,187,213,187,214,188,196,188,245,189,190,189,193,189,221,189,243,189,244,190,221,190,243,192,213,192,214,193,244,193,245,194,201,194,204,194,211,195,197,195,248,195,281,196,197,196,236,197,248,197,419,198,209,198,217,198,236,199,200,199,208,199,396,199,428,200,201,200,208,200,421,200,428,201,208,202,204,202,210,202,212,202,214,203,206,204,210,204,211,205,206,205,207,205,216,206,216,207,212,207,214,207,216,209,217,210,211,210,214,212,214,212,216,213,215,217,236,218,219,218,220,218,237,219,235,220,237,221,222,222,223,223,224,224,225,225,247,226,247,227,234,228,229,229,230,230,231,231,232,232,233,233,244,233,245,235,240,237,239,237,241,238,239,238,241,238,242,239,241,241,242,243,244,244,245,246,247,248,281,248,419,248,456,249,255,249,263,249,339,249,390,250,290,250,309,250,328,250,392,250,458,250,459,250,462,251,284,251,298,251,301,251,389,252,253,252,256,252,374,252,380,252,381,252,450,252,451,253,254,253,373,253,374,253,449,253,450,254,339,254,373,254,448,254,449,255,261,255,263,255,339,255,359,255,446,255,448,256,341,256,381,256,382,256,451,256,452,257,258,257,259,257,386,257,387,257,442,257,443,258,286,258,384,258,385,258,386,258,441,258,442,259,260,259,387,259,443,259,444,260,387,260,388,260,444,260,445,260,466,260,467,261,340,261,346,261,446,261,448,262,369,262,396,262,418,262,421,262,428,262,431,263,359,263,466,263,467,264,356,264,368,264,372,264,383,264,447,264,454,265,340,265,342,265,353,265,372,265,446,266,329,266,330,266,371,266,423,266,425,266,426,267,269,267,302,267,393,268,271,268,302,268,311,268,312,269,270,269,302,269,303,269,322,269,391,269,393,270,303,270,304,270,322,270,409,270,410,271,272,271,302,271,303,271,304,271,311,272,304,272,310,272,311,272,407,272,408,273,287,273,291,273,321,273,335,273,375,273,422,273,424,274,275,274,354,274,440,274,457,275,281,275,363,275,440,276,283,276,293,276,300,276,342,276,353,276,383,276,445,277,329,277,343,277,350,277,355,277,357,277,437,278,279,278,294,278,344,278,360,278,439,278,455,279,294,279,331,279,358,279,360,279,429,280,330,280,346,280,347,280,352,280,411,280,425,281,363,281,456,282,283,282,293,282,295,282,296,282,334,282,442,282,443,283,293,283,443,283,444,283,445,284,298,284,332,284,333,285,295,285,336,285,413,285,417,285,441,286,384,286,398,286,414,286,441,287,291,287,409,287,410,287,422,287,432,288,361,288,397,288,401,288,435,289,290,289,305,289,392,289,439,289,455,290,305,290,328,290,392,290,460,291,306,291,375,291,408,291,409,292,306,292,307,292,308,292,325,292,407,292,415,293,298,293,300,293,301,293,333,293,334,294,327,294,331,294,358,294,455,294,460,295,296,295,336,295,441,295,442,296,299,296,334,296,336,297,299,297,332,297,333,297,337,297,338,298,301,298,333,299,333,299,334,299,336,299,337,300,301,300,368,300,383,301,368,301,389,302,303,303,304,304,408,304,409,305,455,305,460,306,307,306,375,306,407,306,408,307,320,307,321,307,325,307,375,308,324,308,325,308,415,309,392,309,438,309,457,309,459,310,311,310,407,310,415,311,312,313,314,313,405,313,406,313,421,314,315,314,404,314,405,315,316,315,403,315,404,316,317,316,402,316,403,317,402,318,319,318,324,318,325,318,402,318,403,319,320,319,325,319,403,319,404,320,321,320,325,320,404,320,405,321,335,321,375,321,405,321,406,322,391,322,410,322,426,322,436,323,361,323,366,323,447,323,454,324,325,326,327,326,328,326,370,326,391,326,393,326,462,327,328,327,358,327,391,327,423,327,460,328,460,328,462,329,330,329,349,329,350,329,355,329,371,330,347,330,348,330,349,330,425,331,358,332,333,333,334,335,406,335,418,335,424,336,337,337,338,339,373,339,390,339,448,340,345,340,346,340,352,340,372,340,446,341,362,341,382,341,452,341,453,341,463,341,464,342,353,342,445,342,446,342,467,343,357,343,399,343,412,343,437,344,360,344,438,344,439,344,440,345,352,345,366,345,372,345,447,346,347,346,352,346,448,346,449,347,348,347,449,347,450,348,349,348,450,349,350,349,450,349,451,349,452,350,357,350,452,351,412,351,417,351,419,351,465,352,366,352,376,352,401,352,411,353,372,353,383,354,370,354,457,354,461,355,358,355,371,355,429,355,437,356,368,356,389,356,454,357,412,357,452,357,453,357,465,358,371,358,423,358,429,359,446,359,467,360,363,360,420,360,429,360,440,361,366,361,401,362,382,362,398,362,414,362,463,363,420,363,440,363,456,364,365,364,367,364,379,364,394,364,416,364,434,365,367,365,379,365,397,366,401,366,447,367,397,367,416,367,433,367,435,368,383,368,389,369,377,369,395,369,396,369,400,369,431,370,461,370,462,371,423,372,383,372,447,373,374,373,390,374,380,376,401,376,411,376,433,376,435,377,396,377,400,378,379,378,395,378,400,379,394,379,395,380,381,381,382,384,385,384,398,385,386,386,387,387,388,388,466,391,393,391,423,391,426,392,438,392,439,394,395,394,430,394,431,394,434,395,400,395,431,396,428,397,435,398,414,399,412,399,419,399,437,399,456,401,435,402,403,403,404,404,405,405,406,406,418,406,421,407,408,407,415,408,409,409,410,410,432,410,436,411,416,411,425,411,427,411,433,411,434,412,419,412,465,413,414,413,417,413,441,413,463,413,464,414,441,414,463,416,433,416,434,417,464,417,465,418,421,418,424,418,431,419,456,420,429,420,437,420,456,421,428,422,424,422,430,422,432,422,434,423,426,424,430,424,431,425,426,425,427,425,436,426,436,427,432,427,434,427,436,429,437,430,431,430,434,432,434,432,436,433,435,437,456,438,439,438,440,438,457,439,455,440,457,441,442,442,443,443,444,444,445,445,467,446,467,447,454,448,449,449,450,450,451,451,452,452,453,453,464,453,465,455,460,457,459,457,461,458,459,458,461,458,462,459,461,461,462,463,464,464,465,466,467],"c":[0,37,0,267,7,33,7,163,10,109,10,338,13,82,13,312,14,87,14,317,17,84,17,314,21,54,21,162,33,246,37,39,39,40,40,185,46,53,52,53,52,65,54,103,55,65,58,132,58,172,61,146,61,185,63,70,63,105,66,105,66,107,67,103,67,109,78,95,78,191,80,81,80,191,81,82,84,181,87,178,88,95,88,178,91,146,91,181,93,132,93,234,127,162,127,234,133,155,133,173,136,150,136,172,144,145,144,163,145,153,148,152,148,176,149,150,149,176,152,377,153,154,154,155,157,158,157,173,158,159,159,160,160,161,161,246,249,263,249,390,251,284,251,389,263,466,267,269,269,270,270,409,276,283,282,283,282,295,284,332,285,295,288,361,288,397,291,375,291,409,293,300,293,334,296,334,296,336,297,332,297,338,308,324,308,415,310,311,310,415,311,312,314,405,317,402,318,324,318,402,321,375,321,405,323,361,323,454,356,389,356,454,362,382,362,398,365,379,365,397,373,374,373,390,374,380,377,400,378,379,378,400,380,381,381,382,384,385,384,398,385,386,386,387,387,388,388,466]} \ No newline at end of file diff --git a/src/components/general/page-hero.tsx b/src/components/general/page-hero.tsx new file mode 100644 index 0000000..a47aa69 --- /dev/null +++ b/src/components/general/page-hero.tsx @@ -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 ( +
+
+ +
+ + {eyebrow && ( + + {eyebrow} + + )} +

+ {title} +

+ {subtitle && ( + + {subtitle} + + )} + {children && ( +
+ {children} +
+ )} +
+
+ ); +} diff --git a/src/components/lib/layout/Divider.tsx b/src/components/lib/layout/Divider.tsx index b5d1ffb..488857c 100644 --- a/src/components/lib/layout/Divider.tsx +++ b/src/components/lib/layout/Divider.tsx @@ -18,6 +18,7 @@ type Props = DetailedHTMLProps< export default function Divider({ vertical, dashed, ...props }: Props) { return (
); diff --git a/src/components/lib/motion/head-script.ts b/src/components/lib/motion/head-script.ts new file mode 100644 index 0000000..a51d087 --- /dev/null +++ b/src/components/lib/motion/head-script.ts @@ -0,0 +1,11 @@ +/** + * Inline 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){}})();`; diff --git a/src/components/lib/motion/motion.ts b/src/components/lib/motion/motion.ts new file mode 100644 index 0000000..4d9e40a --- /dev/null +++ b/src/components/lib/motion/motion.ts @@ -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, 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("[data-reveal-stagger]").forEach( + revealGroup, + ); + + root.querySelectorAll( + `[data-reveal]:not([${DONE_ATTR}])`, + ).forEach((el) => { + el.setAttribute(DONE_ATTR, ""); + revealOne(el, variantOf(el), delayOf(el)); + }); + + root.querySelectorAll( + `[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()); +} diff --git a/src/components/lib/motion/use-motion.tsx b/src/components/lib/motion/use-motion.tsx new file mode 100644 index 0000000..af79d45 --- /dev/null +++ b/src/components/lib/motion/use-motion.tsx @@ -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?.(); + }; + }, []); +} diff --git a/src/components/lib/three/SceneCanvas.tsx b/src/components/lib/three/SceneCanvas.tsx new file mode 100644 index 0000000..87dd4ea --- /dev/null +++ b/src/components/lib/three/SceneCanvas.tsx @@ -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(null); + const canvasRef = React.useRef(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 ( + + ); +} diff --git a/src/components/lib/three/materials.ts b/src/components/lib/three/materials.ts new file mode 100644 index 0000000..6aff496 --- /dev/null +++ b/src/components/lib/three/materials.ts @@ -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; + }, + }; +} diff --git a/src/components/lib/three/scenes/face.ts b/src/components/lib/three/scenes/face.ts new file mode 100644 index 0000000..57d4dd4 --- /dev/null +++ b/src/components/lib/three/scenes/face.ts @@ -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; diff --git a/src/components/lib/three/scenes/globe.ts b/src/components/lib/three/scenes/globe.ts new file mode 100644 index 0000000..e37f619 --- /dev/null +++ b/src/components/lib/three/scenes/globe.ts @@ -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; diff --git a/src/components/lib/three/scenes/helix.ts b/src/components/lib/three/scenes/helix.ts new file mode 100644 index 0000000..6551bf5 --- /dev/null +++ b/src/components/lib/three/scenes/helix.ts @@ -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; diff --git a/src/components/lib/three/scenes/sphere.ts b/src/components/lib/three/scenes/sphere.ts new file mode 100644 index 0000000..eee2519 --- /dev/null +++ b/src/components/lib/three/scenes/sphere.ts @@ -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(); + 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(); + 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(); + 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; diff --git a/src/components/lib/three/scenes/stack.ts b/src/components/lib/three/scenes/stack.ts new file mode 100644 index 0000000..ef8f3bb --- /dev/null +++ b/src/components/lib/three/scenes/stack.ts @@ -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(); + 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; diff --git a/src/components/lib/three/scenes/waves.ts b/src/components/lib/three/scenes/waves.ts new file mode 100644 index 0000000..48de058 --- /dev/null +++ b/src/components/lib/three/scenes/waves.ts @@ -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; diff --git a/src/components/lib/three/stage.ts b/src/components/lib/three/stage.ts new file mode 100644 index 0000000..c20e0a3 --- /dev/null +++ b/src/components/lib/three/stage.ts @@ -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; + +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(); + }, + }; +} diff --git a/src/components/pages/Home/(sections)/AboutSection.tsx b/src/components/pages/Home/(sections)/AboutSection.tsx index ad69fbe..cc24697 100644 --- a/src/components/pages/Home/(sections)/AboutSection.tsx +++ b/src/components/pages/Home/(sections)/AboutSection.tsx @@ -9,7 +9,10 @@ import { UserRoundSearch } from "lucide-react"; export default function AboutSection() { return (
- +

I Build & Operate Production Systems

diff --git a/src/components/pages/Home/(sections)/MySkillsSection.tsx b/src/components/pages/Home/(sections)/MySkillsSection.tsx index 3e52adb..33ce456 100644 --- a/src/components/pages/Home/(sections)/MySkillsSection.tsx +++ b/src/components/pages/Home/(sections)/MySkillsSection.tsx @@ -22,7 +22,7 @@ export default function MySkillsSection({ noTitle, expand }: Props) { const categories = Object.keys(skills) as (keyof typeof skills)[]; const TitleSection = ( - + {noTitle ? null : ( @@ -65,7 +65,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) { -
+
{skills[ctgr].portfolio.map( (portfolio, index) => { return ( @@ -119,7 +122,10 @@ export default function MySkillsSection({ noTitle, expand }: Props) { })} -
+
{skills[category].portfolio.map((portfolio, index) => { return ( + {!noTitle && ( @@ -61,7 +61,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) {

{ctgr}

-
+
{work[ctgr].portfolio.map( (portfolio, index) => { return ( @@ -113,7 +116,10 @@ export default function MyWorkSection({ noTitle, expand }: Props) { })} -
+
{work[category].portfolio.map((portfolio, index) => { return ( - +

Need Help With Infrastructure?

Whether it's CI/CD, cost optimization, or production diff --git a/src/components/pages/Home/index.tsx b/src/components/pages/Home/index.tsx index 7c92f8f..a0ca359 100644 --- a/src/components/pages/Home/index.tsx +++ b/src/components/pages/Home/index.tsx @@ -7,20 +7,44 @@ import Span from "@/src/components/lib/layout/Span"; import Stack from "@/src/components/lib/layout/Stack"; import { Contact, ScrollText, Terminal } from "lucide-react"; import ResumeButton from "../../general/resume-button"; +import SceneCanvas from "../../lib/three/SceneCanvas"; export default function Main() { return ( -
- - Benjamin Toby -

+
+
+ +
+ + Benjamin Toby +

CTO, DevOps & Platform Engineer.

- + I help teams ship faster, cut infrastructure costs, and achieve reliable production systems. - + 99.99% uptime targets @@ -34,7 +58,11 @@ export default function Main() { Over 80% Operations cost reduction - + - -
-
+ + + + +

+ ); } diff --git a/src/components/pages/blog/(sections)/BlogPostsList.tsx b/src/components/pages/blog/(sections)/BlogPostsList.tsx index a81de42..e414598 100644 --- a/src/components/pages/blog/(sections)/BlogPostsList.tsx +++ b/src/components/pages/blog/(sections)/BlogPostsList.tsx @@ -28,7 +28,7 @@ export default function BlogPostsList() { return (
- + {blogPosts.map((post, index) => { return ; })} diff --git a/src/components/pages/blog/(sections)/Hero.tsx b/src/components/pages/blog/(sections)/Hero.tsx index 7bf0309..73e4188 100644 --- a/src/components/pages/blog/(sections)/Hero.tsx +++ b/src/components/pages/blog/(sections)/Hero.tsx @@ -1,22 +1,12 @@ -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 { AppContext } from "@/src/pages/__root"; -import React from "react"; +import PageHero from "@/src/components/general/page-hero"; export default function Hero() { - const { pageProps } = React.useContext(AppContext); - return ( -
- -

Tech Musings

- - A few takes and tips from my experience and knowledge in the - tech industry - -
-
+ ); } diff --git a/src/components/pages/blog/slug/index.tsx b/src/components/pages/blog/slug/index.tsx index 6763e7d..891a85f 100644 --- a/src/components/pages/blog/slug/index.tsx +++ b/src/components/pages/blog/slug/index.tsx @@ -19,7 +19,9 @@ export default function Main() { {/* */} -

{blogPost?.title}

+

+ {blogPost?.title} +

{blogPost?.excerpt} - -

Contact Me

- - Need help with CI/CD, cost optimization, or production - reliability? Book a call or send a message. - - - - - - ben@tben.me - - -
-
+ + + + + ben@tben.me + + + ); } diff --git a/src/components/pages/skills/index.tsx b/src/components/pages/skills/index.tsx index 6d2cd9f..b5f606d 100644 --- a/src/components/pages/skills/index.tsx +++ b/src/components/pages/skills/index.tsx @@ -1,15 +1,12 @@ -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 PageHero from "../../general/page-hero"; export default function Main() { return ( -
- -

My Skills

- A summary of the tools I've mastered over the years -
-
+ ); } diff --git a/src/components/pages/work/index.tsx b/src/components/pages/work/index.tsx index 26e132b..7914cad 100644 --- a/src/components/pages/work/index.tsx +++ b/src/components/pages/work/index.tsx @@ -1,15 +1,12 @@ -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 PageHero from "../../general/page-hero"; export default function Main() { return ( -
- -

My Work

- Some of My Work -
-
+ ); } diff --git a/src/layouts/main/Aside.tsx b/src/layouts/main/Aside.tsx index aa5f210..fd09bff 100644 --- a/src/layouts/main/Aside.tsx +++ b/src/layouts/main/Aside.tsx @@ -21,10 +21,14 @@ export default function Aside({}: Props) { "px-6 py-4 h-[var(--header-height)] border-0", "border-b border-white/10", )} + data-reveal="fade" >
- + {SocialLinks.map((link, index) => { return ; })} diff --git a/src/layouts/main/Header.tsx b/src/layouts/main/Header.tsx index 20dd7fe..649fcd8 100644 --- a/src/layouts/main/Header.tsx +++ b/src/layouts/main/Header.tsx @@ -21,10 +21,13 @@ export default function Header({ menuOpen, setMenuOpen }: Props) { "bg-background-dark z-10", )} > - + - +