Refactor code structure for improved readability and maintainability
Some checks failed
/ print-content (push) Failing after 1m44s

This commit is contained in:
암냥 2026-09-10 20:53:15 +09:00
commit 921b67b553
9 changed files with 415 additions and 21 deletions

View file

@ -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>