Refactor code structure for improved readability and maintainability
Some checks failed
/ print-content (push) Failing after 1m44s
Some checks failed
/ print-content (push) Failing after 1m44s
This commit is contained in:
parent
68ac80ac12
commit
921b67b553
9 changed files with 415 additions and 21 deletions
18
bun.lock
18
bun.lock
|
|
@ -8,10 +8,12 @@
|
|||
"@sun-typeface/suit": "^2.0.5",
|
||||
"astro": "7.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"three": "^0.186.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/three": "^0.185.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
},
|
||||
},
|
||||
|
|
@ -101,6 +103,8 @@
|
|||
|
||||
"@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="],
|
||||
|
||||
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
|
@ -309,6 +313,8 @@
|
|||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
|
@ -321,8 +327,14 @@
|
|||
|
||||
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
|
||||
|
||||
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
||||
|
||||
"@types/three": ["@types/three@0.185.4", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA=="],
|
||||
|
||||
"@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.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
|
||||
|
||||
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
|
||||
|
|
@ -417,6 +429,8 @@
|
|||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"find-proc": ["find-proc@0.1.0", "", {}, "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg=="],
|
||||
|
||||
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
|
||||
|
|
@ -491,6 +505,8 @@
|
|||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
|
@ -591,6 +607,8 @@
|
|||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyclip": ["tinyclip@0.1.13", "", {}, "sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ=="],
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@
|
|||
"dependencies": {
|
||||
"@sun-typeface/suit": "^2.0.5",
|
||||
"astro": "7.3.1",
|
||||
"clsx": "^2.1.1"
|
||||
"clsx": "^2.1.1",
|
||||
"three": "^0.186.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/three": "^0.185.4",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
BIN
public/about/cards/0.avif
Normal file
BIN
public/about/cards/0.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
public/about/cards/1.avif
Normal file
BIN
public/about/cards/1.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
public/about/cards/2.avif
Normal file
BIN
public/about/cards/2.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
public/about/cards/3.avif
Normal file
BIN
public/about/cards/3.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
public/about/cards/4.avif
Normal file
BIN
public/about/cards/4.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
public/about/cards/back.avif
Normal file
BIN
public/about/cards/back.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -1,5 +1,13 @@
|
|||
---
|
||||
import AboutRow from "@/components/AboutRow.astro";
|
||||
|
||||
const cards = [
|
||||
{ src: "/about/cards/0.avif", alt: "활동 사진 카드" },
|
||||
{ src: "/about/cards/1.avif", alt: "활동 사진 카드" },
|
||||
{ src: "/about/cards/2.avif", alt: "활동 일러스트 카드" },
|
||||
{ src: "/about/cards/3.avif", alt: "활동 일러스트 카드" },
|
||||
{ src: "/about/cards/4.avif", alt: "활동 사진 카드" },
|
||||
];
|
||||
---
|
||||
|
||||
<section
|
||||
|
|
@ -8,7 +16,7 @@ import AboutRow from "@/components/AboutRow.astro";
|
|||
class="min-h-[calc(100svh-64px)] snap-start bg-background text-foreground"
|
||||
aria-labelledby="about-title"
|
||||
>
|
||||
<div class="grid min-h-full grid-cols-1 lg:grid-cols-2">
|
||||
<div class="grid min-h-full grid-cols-1 lg:grid-cols-[45fr_55fr]">
|
||||
<div
|
||||
class="relative z-10 flex flex-col justify-center px-6 py-16 sm:px-10 lg:px-[clamp(52px,5.208vw,100px)] lg:py-[clamp(58px,9.26vh,100px)]"
|
||||
>
|
||||
|
|
@ -16,8 +24,8 @@ import AboutRow from "@/components/AboutRow.astro";
|
|||
About<span class="text-foreground/35" aria-hidden="true">.</span>
|
||||
</h2>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-foreground">
|
||||
안녕하세요. 정보보안과 소프트웨어 엔지니어링을 공부하는 암냥입니다.
|
||||
새로운 기술을 배우고, 머릿속의 아이디어를 실제로 작동하는 것으로
|
||||
안녕하세요. 정보보안과 소프트웨어 엔지니어링을 공부하는 암냥입니다.<br/>
|
||||
새로운 기술을 배우고, 머릿속의 아이디어를 실제로 작동하는 것으로<br/>
|
||||
만드는 일을 좋아해요.
|
||||
</p>
|
||||
<section class="mt-16" aria-labelledby="experience-title">
|
||||
|
|
@ -93,23 +101,389 @@ import AboutRow from "@/components/AboutRow.astro";
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative order-last h-[min(100vw,480px)] min-h-0 overflow-hidden bg-muted lg:sticky lg:top-0 lg:order-none lg:h-[calc(100svh-64px)] lg:min-h-0 lg:self-start"
|
||||
aria-label="활동 사진"
|
||||
>
|
||||
<img
|
||||
class="absolute inset-0 h-full w-full select-none object-cover object-center"
|
||||
src="/about/hand.avif"
|
||||
alt="손"
|
||||
width="1920"
|
||||
height="1080"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-background/35 via-transparent to-black/5"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="about-card-panel order-last min-h-[min(100vw,540px)] overflow-hidden bg-muted lg:sticky lg:top-0 lg:order-none lg:h-[calc(100svh-64px)] lg:min-h-0" aria-label="활동 사진 선택">
|
||||
<div class="about-card-stage" data-card-stage>
|
||||
<canvas class="about-card-canvas" data-card-canvas aria-hidden="true"></canvas>
|
||||
<div class="about-card-access" role="group" aria-label="활동 카드 선택">
|
||||
{cards.map((card, index) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`활동 카드 ${index + 1} 선택`}
|
||||
aria-pressed="false"
|
||||
data-card-control={index}
|
||||
data-card-src={card.src}
|
||||
>
|
||||
{card.alt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button class="about-card-flip" type="button" data-card-flip aria-label="선택한 카드 앞뒷면 전환" hidden>
|
||||
<span aria-hidden="true">↻</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import * as THREE from "three/src/Three.js";
|
||||
|
||||
const stage = document.querySelector<HTMLElement>("[data-card-stage]");
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("[data-card-canvas]");
|
||||
const aboutSection = stage?.closest<HTMLElement>("#about");
|
||||
const controls = [...document.querySelectorAll<HTMLButtonElement>("[data-card-control]")];
|
||||
const flipButton = document.querySelector<HTMLButtonElement>("[data-card-flip]");
|
||||
|
||||
if (stage && canvas && controls.length) {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(27, 1, 0.1, 100);
|
||||
camera.position.z = 15;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.25));
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x4b3447, 1.8));
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 3.6);
|
||||
keyLight.position.set(-4, 6, 8);
|
||||
scene.add(keyLight);
|
||||
const glowLight = new THREE.PointLight(0xffc8e1, 2.4, 12);
|
||||
glowLight.position.set(0, 1.5, 6);
|
||||
scene.add(glowLight);
|
||||
|
||||
const cardWidth = 2.481875;
|
||||
const cardHeight = 3.474625;
|
||||
const cardDepth = 0.028;
|
||||
const cardCornerRadius = 6;
|
||||
const fanOffsets = [-1.95, -0.98, 0, 0.98, 1.95];
|
||||
const fanHeights = [-1.45, -1.34, -1.38, -1.34, -1.45];
|
||||
const fanRotations = [-0.3, -0.15, 0, 0.15, 0.3];
|
||||
const loader = new THREE.TextureLoader();
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
function createCardTexture(image: HTMLImageElement) {
|
||||
const texture = new THREE.Texture(image);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createRoundedAlphaTexture() {
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = 256;
|
||||
maskCanvas.height = 360;
|
||||
const context = maskCanvas.getContext("2d");
|
||||
if (!context) return new THREE.Texture();
|
||||
|
||||
context.fillStyle = "white";
|
||||
context.beginPath();
|
||||
context.roundRect(2, 2, 252, 356, cardCornerRadius);
|
||||
context.fill();
|
||||
|
||||
const texture = new THREE.CanvasTexture(maskCanvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
const roundedAlphaTexture = createRoundedAlphaTexture();
|
||||
let loadedTextureCount = 0;
|
||||
let texturesReady = false;
|
||||
let sectionVisible = false;
|
||||
let deckLiftStarted = false;
|
||||
let fanExpanded = false;
|
||||
let deckLiftTimer: number | undefined;
|
||||
const markTextureReady = () => {
|
||||
loadedTextureCount += 1;
|
||||
if (loadedTextureCount === controls.length + 1) {
|
||||
texturesReady = true;
|
||||
startDeckReveal();
|
||||
}
|
||||
scheduleRender();
|
||||
};
|
||||
const cards = controls.map((control, index) => {
|
||||
const group = new THREE.Group();
|
||||
const frontMaterial = new THREE.MeshBasicMaterial({
|
||||
alphaMap: roundedAlphaTexture,
|
||||
transparent: true,
|
||||
});
|
||||
const front = new THREE.Mesh(new THREE.PlaneGeometry(cardWidth, cardHeight), frontMaterial);
|
||||
loader.load(control.dataset.cardSrc ?? "", (texture: THREE.Texture) => {
|
||||
frontMaterial.map = createCardTexture(texture.image as HTMLImageElement);
|
||||
frontMaterial.needsUpdate = true;
|
||||
front.visible = true;
|
||||
markTextureReady();
|
||||
scheduleRender();
|
||||
});
|
||||
const backMaterial = new THREE.MeshBasicMaterial({
|
||||
alphaMap: roundedAlphaTexture,
|
||||
transparent: true,
|
||||
});
|
||||
const back = new THREE.Mesh(new THREE.PlaneGeometry(cardWidth, cardHeight), backMaterial);
|
||||
front.visible = false;
|
||||
back.visible = false;
|
||||
front.position.z = cardDepth / 2 + 0.008;
|
||||
back.position.z = -cardDepth / 2 - 0.008;
|
||||
back.rotation.y = Math.PI;
|
||||
group.add(front, back);
|
||||
group.userData = { index };
|
||||
group.position.set(0, -0.28, index * 0.04);
|
||||
group.rotation.y = Math.PI;
|
||||
group.rotation.z = 0;
|
||||
scene.add(group);
|
||||
return group;
|
||||
});
|
||||
|
||||
loader.load("/about/cards/back.avif", (texture: THREE.Texture) => {
|
||||
const backTexture = createCardTexture(texture.image as HTMLImageElement);
|
||||
cards.forEach((card) => {
|
||||
const back = card.children[1] as THREE.Mesh;
|
||||
const material = back.material as THREE.MeshBasicMaterial;
|
||||
material.map = backTexture;
|
||||
material.needsUpdate = true;
|
||||
back.visible = true;
|
||||
});
|
||||
markTextureReady();
|
||||
scheduleRender();
|
||||
});
|
||||
|
||||
let selectedIndex = -1;
|
||||
let revealedIndex = -1;
|
||||
let revealTimer: number | undefined;
|
||||
let animationFrameId: number | undefined;
|
||||
const pointerDown = new THREE.Vector2();
|
||||
|
||||
function scheduleRender() {
|
||||
if (animationFrameId === undefined) animationFrameId = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function startDeckReveal() {
|
||||
if (!texturesReady || !sectionVisible || deckLiftStarted) return;
|
||||
deckLiftStarted = true;
|
||||
scheduleRender();
|
||||
deckLiftTimer = window.setTimeout(() => {
|
||||
fanExpanded = true;
|
||||
scheduleRender();
|
||||
}, 620);
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
controls.forEach((control, index) => {
|
||||
control.setAttribute("aria-pressed", String(index === selectedIndex));
|
||||
});
|
||||
if (flipButton) flipButton.hidden = selectedIndex < 0;
|
||||
}
|
||||
|
||||
function selectCard(index: number) {
|
||||
if (revealTimer !== undefined) window.clearTimeout(revealTimer);
|
||||
if (index === selectedIndex) {
|
||||
selectedIndex = -1;
|
||||
revealedIndex = -1;
|
||||
updateControls();
|
||||
scheduleRender();
|
||||
return;
|
||||
}
|
||||
selectedIndex = index;
|
||||
revealedIndex = -1;
|
||||
updateControls();
|
||||
revealTimer = window.setTimeout(() => {
|
||||
if (selectedIndex === index) revealedIndex = index;
|
||||
scheduleRender();
|
||||
}, 280);
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function setPointer(event: PointerEvent) {
|
||||
const bounds = canvas!.getBoundingClientRect();
|
||||
pointer.set(
|
||||
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
||||
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function hitCard() {
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hit = raycaster.intersectObjects(cards, true)[0];
|
||||
return hit?.object.parent?.userData.index ?? hit?.object.userData.index ?? -1;
|
||||
}
|
||||
|
||||
controls.forEach((control, index) => {
|
||||
control.addEventListener("click", () => selectCard(index));
|
||||
});
|
||||
flipButton?.addEventListener("click", () => {
|
||||
if (selectedIndex >= 0) {
|
||||
revealedIndex = revealedIndex === selectedIndex ? -1 : selectedIndex;
|
||||
scheduleRender();
|
||||
}
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
setPointer(event);
|
||||
});
|
||||
canvas.addEventListener("pointerleave", () => {
|
||||
canvas.style.cursor = "grab";
|
||||
});
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
pointerDown.set(event.clientX, event.clientY);
|
||||
});
|
||||
canvas.addEventListener("pointerup", (event) => {
|
||||
if (Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y) > 8) return;
|
||||
setPointer(event);
|
||||
const index = hitCard();
|
||||
if (index >= 0) selectCard(index);
|
||||
});
|
||||
|
||||
const scrollRoot = aboutSection?.closest<HTMLElement>("main") ?? null;
|
||||
const revealObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
sectionVisible = true;
|
||||
startDeckReveal();
|
||||
scheduleRender();
|
||||
}
|
||||
},
|
||||
{ root: scrollRoot, threshold: 0.35 },
|
||||
);
|
||||
if (aboutSection) revealObserver.observe(aboutSection);
|
||||
|
||||
function resize() {
|
||||
const { width, height } = stage!.getBoundingClientRect();
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
const visibleWidth = 2 * camera.position.z * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camera.aspect;
|
||||
const scale = Math.min(1, Math.max(0.58, (visibleWidth / (fanOffsets.at(-1)! * 2 + cardWidth)) * 0.92));
|
||||
scene.scale.setScalar(scale * 0.9);
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(stage);
|
||||
resize();
|
||||
updateControls();
|
||||
|
||||
let previousTime = performance.now();
|
||||
function render() {
|
||||
animationFrameId = undefined;
|
||||
const now = performance.now();
|
||||
const delta = Math.min((now - previousTime) / 1000, 0.05);
|
||||
previousTime = now;
|
||||
let moving = false;
|
||||
cards.forEach((card, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
card.renderOrder = selected ? 20 : index;
|
||||
const targetX = fanExpanded ? fanOffsets[index] : 0;
|
||||
const targetY = selected
|
||||
? fanHeights[index] + 2
|
||||
: (fanExpanded ? fanHeights[index] : (deckLiftStarted ? -0.28 : -2.4));
|
||||
const targetZ = selected ? 2.6 : index * 0.04;
|
||||
const targetScale = selected ? 1.05 : 1;
|
||||
card.position.x = THREE.MathUtils.damp(card.position.x, targetX, 5.5, delta);
|
||||
card.position.y = THREE.MathUtils.damp(card.position.y, targetY, 5.5, delta);
|
||||
card.position.z = THREE.MathUtils.damp(card.position.z, targetZ, 5.5, delta);
|
||||
card.scale.setScalar(THREE.MathUtils.damp(card.scale.x, targetScale, 5.5, delta));
|
||||
const targetRotation = fanExpanded ? fanRotations[index] : 0;
|
||||
card.rotation.z = THREE.MathUtils.damp(card.rotation.z, targetRotation, 5.5, delta);
|
||||
card.rotation.y = THREE.MathUtils.damp(card.rotation.y, revealedIndex === index ? 0 : Math.PI, 7, delta);
|
||||
moving ||= Math.abs(card.position.x - targetX) > 0.001
|
||||
|| Math.abs(card.position.y - targetY) > 0.001
|
||||
|| Math.abs(card.position.z - targetZ) > 0.001
|
||||
|| Math.abs(card.rotation.z - targetRotation) > 0.001
|
||||
|| Math.abs(card.rotation.y - (revealedIndex === index ? 0 : Math.PI)) > 0.001;
|
||||
});
|
||||
renderer.render(scene, camera);
|
||||
if (moving) scheduleRender();
|
||||
}
|
||||
scheduleRender();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.about-card-panel {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.about-card-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
height: 100%;
|
||||
min-height: 540px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--background) 96%, white), var(--muted));
|
||||
}
|
||||
|
||||
.about-card-stage::before {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 16%;
|
||||
width: min(78%, 640px);
|
||||
height: 18%;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--foreground) 14%, transparent);
|
||||
content: "";
|
||||
filter: blur(28px);
|
||||
opacity: .22;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.about-card-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.about-card-access {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.about-card-access button {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.about-card-flip {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
bottom: 28px;
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
z-index: 2;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--background) 72%, transparent);
|
||||
font-size: 21px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 24px rgb(32 17 29 / 10%);
|
||||
transition: transform 220ms ease, background 220ms ease, box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.about-card-flip:hover,
|
||||
.about-card-flip:focus-visible {
|
||||
outline: none;
|
||||
background: var(--background);
|
||||
transform: rotate(45deg);
|
||||
box-shadow: 0 10px 28px rgb(32 17 29 / 16%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.about-card-flip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue