489 lines
19 KiB
Text
489 lines
19 KiB
Text
---
|
|
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
|
|
id="about"
|
|
data-node-id="4:104"
|
|
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-[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)]"
|
|
>
|
|
<h2 id="about-title" class="max-w-xl text-3xl font-semibold tracking-tight sm:text-4xl">
|
|
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">
|
|
<div class="mb-5 flex items-baseline justify-between gap-4">
|
|
<h3 id="experience-title" class="text-sm font-semibold tracking-wide">지금까지</h3>
|
|
</div>
|
|
<div class="flex flex-col gap-[0.55em]">
|
|
<AboutRow year="2026" connector="wave">
|
|
<img
|
|
class="h-[1em] w-[1em] shrink-0"
|
|
src="/about/layer7-mark.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<span>선린인터넷고등학교 정보보안 동아리</span>
|
|
<img
|
|
class="h-[1em] w-[1em] shrink-0 [filter:var(--mono-filter)]"
|
|
src="/about/layer7-wordmark.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<span>Layer7</span>
|
|
</AboutRow>
|
|
|
|
<AboutRow year="2026" connector="wave">
|
|
<img
|
|
class="h-[1em] w-[1em] shrink-0"
|
|
src="/about/sunrin.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<span>선린인터넷고등학교 정보보호과</span>
|
|
</AboutRow>
|
|
<AboutRow year="2026" connector="line">
|
|
<img
|
|
class="h-[1em] w-[1em] shrink-0 object-contain"
|
|
src="/about/adofai.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<span>ADOFAI.gg Development Team</span>
|
|
</AboutRow>
|
|
<AboutRow year="2025" connector="line">
|
|
<img
|
|
class="h-[1em] w-[1em] shrink-0 object-contain"
|
|
src="/about/whitehat.avif"
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<span>화이트햇스쿨 3기 이수</span>
|
|
</AboutRow>
|
|
</div>
|
|
</section>
|
|
<div class="mt-8 flex flex-row items-center text-sm text-foreground/60">
|
|
<a
|
|
href="#experience"
|
|
class="flex flex-row items-center underline underline-offset-4 hover:text-foreground"
|
|
><span>전체 타임라인 보기</span>
|
|
<svg
|
|
aria-hidden="true"
|
|
class="h-4 text-foreground/40"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M7 17 17 7"></path>
|
|
<path d="M7 7h10v10"></path>
|
|
</svg></a
|
|
>
|
|
</div>
|
|
</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>
|