feat: build responsive Three.js river journey

This commit is contained in:
2026-09-05 17:22:12 +08:00
parent 637da07c9e
commit f6228f8507
5 changed files with 420 additions and 215 deletions

View File

@@ -2,171 +2,217 @@
position: relative; position: relative;
width: 100%; width: 100%;
height: 1050px; height: 1050px;
/* Seamless continuation of scenic artwork */ overflow: hidden;
background: var(--bg-page);
}
.journeySection::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(to bottom, var(--bg-page), transparent 10%, transparent 90%, var(--bg-page)),
radial-gradient(ellipse at center, transparent 48%, var(--bg-page) 100%);
opacity: 0.62;
} }
.introBlock { .introBlock {
position: absolute; position: absolute;
left: 60px; top: 112px;
top: 170px; left: var(--layout-page-gutter);
width: 380px; z-index: 3;
z-index: 5; width: min(390px, calc(100% - (2 * var(--layout-page-gutter))));
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--overlay-contact);
backdrop-filter: blur(8px);
} }
.eyebrow { .eyebrow {
display: block; display: block;
margin-bottom: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-size-label-small); font-size: var(--font-size-label-small);
font-weight: 700;
line-height: var(--line-height-label-small); line-height: var(--line-height-label-small);
letter-spacing: var(--letter-spacing-eyebrow); letter-spacing: var(--letter-spacing-eyebrow);
text-transform: uppercase; text-transform: uppercase;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 8px;
} }
.heading { .heading {
font-size: var(--font-size-h2); margin-bottom: var(--space-3);
line-height: var(--line-height-h2);
font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 12px; font-size: var(--font-size-h2);
font-weight: 700;
line-height: var(--line-height-h2);
} }
.underlineBar { .underlineBar {
width: 120px; width: 120px;
height: 3px; height: 3px;
background-color: var(--accent-default); margin-bottom: var(--space-5);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
margin-bottom: 24px; background: var(--accent-default);
} }
.description { .description {
font-size: var(--font-size-body-large);
line-height: var(--line-height-body-large);
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--font-size-body-large);
font-weight: 400; font-weight: 400;
line-height: var(--line-height-body-large);
} }
/* Step Typography */ .stepsList {
position: absolute;
inset: 0;
z-index: 3;
list-style: none;
}
.stepCard {
position: absolute;
display: flex;
width: 250px;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--overlay-contact);
box-shadow: 0 8px 24px var(--shadow-color);
backdrop-filter: blur(8px);
transition:
border-color var(--motion-duration-fast) ease,
transform var(--motion-duration-default) var(--motion-ease-out);
}
.step05 { top: 116px; right: 46px; }
.step04 { top: 334px; right: 250px; }
.step03 { top: 500px; left: 48%; }
.step02 { top: 690px; left: 25%; }
.step01 { top: 826px; left: 70px; }
.stepNumber { .stepNumber {
font-size: 20px; flex: 0 0 auto;
font-weight: 700;
color: var(--accent-default); color: var(--accent-default);
margin-bottom: 4px; font-size: 20px;
font-weight: 800;
line-height: 28px;
} }
.stepTitle { .stepTitle {
font-size: var(--font-size-h3); margin-bottom: var(--space-1);
line-height: var(--line-height-h3);
font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 6px; font-size: var(--font-size-h3);
font-weight: 700;
line-height: var(--line-height-h3);
} }
.stepDesc { .stepDesc {
color: var(--text-secondary);
font-size: var(--font-size-body-small); font-size: var(--font-size-body-small);
line-height: var(--line-height-body-small); line-height: var(--line-height-body-small);
color: var(--text-secondary);
} }
.islandImage { @media (max-width: 1024px) {
object-fit: contain; .introBlock {
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.08)); top: 80px;
transition: transform 0.3s ease; }
.stepCard {
width: 230px;
}
.step05 { top: 90px; right: 32px; }
.step04 { top: 300px; right: 10%; }
.step03 { top: 490px; left: 48%; }
.step02 { top: 690px; left: 24%; }
.step01 { top: 850px; left: 32px; }
} }
.islandImage:hover { @media (max-width: 760px) {
transform: translateY(-4px) scale(1.02); .journeySection {
height: auto;
min-height: 1120px;
padding: var(--layout-section-padding) var(--layout-page-gutter);
}
.journeySection::after {
background: linear-gradient(to bottom, transparent 0 28%, var(--bg-page) 54%);
opacity: 0.82;
}
.introBlock {
position: relative;
top: auto;
left: auto;
width: 100%;
padding: 0;
background: transparent;
backdrop-filter: none;
}
.heading {
font-size: 28px;
line-height: 38px;
}
.description {
font-size: var(--font-size-body-default);
line-height: var(--line-height-body-default);
}
.stepsList {
position: relative;
inset: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: 300px;
padding-left: 20px;
}
.stepsList::before {
content: '';
position: absolute;
top: 20px;
bottom: 20px;
left: 5px;
width: 1px;
background: var(--border-strong);
}
.stepCard,
.step05,
.step04,
.step03,
.step02,
.step01 {
position: relative;
inset: auto;
width: 100%;
padding: var(--space-3) var(--space-4);
box-shadow: none;
backdrop-filter: blur(6px);
}
.stepCard::before {
content: '';
position: absolute;
top: 22px;
left: -20px;
width: 9px;
height: 9px;
border: 3px solid var(--bg-page);
border-radius: 50%;
background: var(--accent-default);
}
} }
/* Freely Positioned Milestones */ @media (hover: hover) and (pointer: fine) {
.stepCard:hover {
/* Step 05: Product Engineer */ border-color: var(--accent-default);
.step05Graphic { transform: translateY(-2px);
position: absolute; }
left: 1030px;
top: 100px;
width: 180px;
height: 240px;
z-index: 6;
}
.step05Text {
position: absolute;
left: 1220px;
top: 150px;
width: 190px;
z-index: 6;
}
/* Step 04: Software Engineer */
.step04Text {
position: absolute;
left: 650px;
top: 230px;
width: 200px;
z-index: 6;
}
.step04Graphic {
position: absolute;
left: 810px;
top: 200px;
width: 250px;
height: 220px;
z-index: 6;
}
/* Step 03: Computer Science */
.step03Text {
position: absolute;
left: 590px;
top: 425px;
width: 200px;
z-index: 6;
}
.step03Graphic {
position: absolute;
left: 750px;
top: 390px;
width: 240px;
height: 220px;
z-index: 6;
}
/* Step 02: UI/UX Designer */
.step02Text {
position: absolute;
left: 490px;
top: 570px;
width: 180px;
z-index: 6;
}
.step02Graphic {
position: absolute;
left: 620px;
top: 550px;
width: 210px;
height: 200px;
z-index: 6;
}
/* Step 01: Digital Artist */
.step01Text {
position: absolute;
left: 80px;
top: 650px;
width: 210px;
z-index: 6;
}
.step01Graphic {
position: absolute;
left: 260px;
top: 610px;
width: 230px;
height: 200px;
z-index: 6;
} }

View File

@@ -1,16 +1,25 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Image from 'next/image'; import { AboutRiverScene } from './scenes/AboutRiverScene';
import styles from './RiverJourney.module.css'; import styles from './RiverJourney.module.css';
const steps = [
{ number: '05', title: 'Product Engineer', description: 'Merging product strategy with engineering to ship impactful products.' },
{ number: '04', title: 'Software Engineer', description: 'Engineered scalable applications and delivered real-world solutions.' },
{ number: '03', title: 'Computer Science', description: 'Built strong foundations in algorithms and systems thinking.' },
{ number: '02', title: 'UI/UX Designer', description: 'Designed intuitive interfaces and user-centered experiences.' },
{ number: '01', title: 'Digital Artist', description: 'Explored visual design, illustration and storytelling.' },
];
export const RiverJourney: React.FC = () => { export const RiverJourney: React.FC = () => {
return ( return (
<section id="about" className={styles.journeySection}> <section id="about" className={styles.journeySection} aria-labelledby="about-title">
<AboutRiverScene />
{/* Intro Header & Description on the Left */} {/* Intro Header & Description on the Left */}
<div className={styles.introBlock}> <div className={styles.introBlock}>
<span className={styles.eyebrow}>ABOUT ME</span> <span className={styles.eyebrow}>ABOUT ME</span>
<h2 className={styles.heading}>From Pixel to Product</h2> <h2 id="about-title" className={styles.heading}>From Pixel to Product</h2>
<div className={styles.underlineBar} /> <div className={styles.underlineBar} />
<p className={styles.description}> <p className={styles.description}>
I began as a digital artist, designing visual stories. Curiosity turned I began as a digital artist, designing visual stories. Curiosity turned
@@ -20,97 +29,17 @@ export const RiverJourney: React.FC = () => {
</p> </p>
</div> </div>
{/* 5 Freely Positioned Milestones along the River */} <ol className={styles.stepsList} aria-label="Career journey">
{steps.map((step) => (
{/* Step 05: Product Engineer */} <li key={step.number} className={`${styles.stepCard} ${styles[`step${step.number}`]}`}>
<div className={styles.step05Graphic}> <span className={styles.stepNumber}>{step.number}</span>
<Image <div>
src="/assets/step-05-product-engineer.png" <h3 className={styles.stepTitle}>{step.title}</h3>
alt="Product Engineer Milestone" <p className={styles.stepDesc}>{step.description}</p>
width={180} </div>
height={240} </li>
className={styles.islandImage} ))}
/> </ol>
</div>
<div className={styles.step05Text}>
<div className={styles.stepNumber}>05</div>
<h3 className={styles.stepTitle}>Product Engineer</h3>
<p className={styles.stepDesc}>
Merging product strategy with engineering to ship impactful products.
</p>
</div>
{/* Step 04: Software Engineer */}
<div className={styles.step04Text}>
<div className={styles.stepNumber}>04</div>
<h3 className={styles.stepTitle}>Software Engineer</h3>
<p className={styles.stepDesc}>
Engineered scalable applications and delivered real-world solutions.
</p>
</div>
<div className={styles.step04Graphic}>
<Image
src="/assets/step-04-software-engineer.png"
alt="Software Engineer Milestone"
width={250}
height={220}
className={styles.islandImage}
/>
</div>
{/* Step 03: Computer Science */}
<div className={styles.step03Text}>
<div className={styles.stepNumber}>03</div>
<h3 className={styles.stepTitle}>Computer Science</h3>
<p className={styles.stepDesc}>
Built strong foundations in algorithms and systems thinking.
</p>
</div>
<div className={styles.step03Graphic}>
<Image
src="/assets/step-02-computer-science.png"
alt="Computer Science Milestone"
width={240}
height={220}
className={styles.islandImage}
/>
</div>
{/* Step 02: UI/UX Designer */}
<div className={styles.step02Text}>
<div className={styles.stepNumber}>02</div>
<h3 className={styles.stepTitle}>UI/UX Designer</h3>
<p className={styles.stepDesc}>
Designed Intuitive interfaces and user-centered experiences.
</p>
</div>
<div className={styles.step02Graphic}>
<Image
src="/assets/step-03-ui-ux-designer.png"
alt="UI/UX Designer Milestone"
width={210}
height={200}
className={styles.islandImage}
/>
</div>
{/* Step 01: Digital Artist */}
<div className={styles.step01Text}>
<div className={styles.stepNumber}>01</div>
<h3 className={styles.stepTitle}>Digital Artist</h3>
<p className={styles.stepDesc}>
Explored visual design, illustration and storytelling.
</p>
</div>
<div className={styles.step01Graphic}>
<Image
src="/assets/step-01-digital-artist.png"
alt="Digital Artist Milestone"
width={230}
height={200}
className={styles.islandImage}
/>
</div>
</section> </section>
); );
}; };

View File

@@ -0,0 +1,199 @@
'use client';
import * as THREE from 'three';
import { useTheme } from '@/context/ThemeContext';
import { ThreeCanvas, type SceneFactory } from './ThreeCanvas';
import {
addSoftLights,
createRibbonShape,
createTree,
fitOrthographicCamera,
palettes,
} from './scenePrimitives';
import styles from './Scenes.module.css';
const riverPath = [
new THREE.Vector2(4.5, -13),
new THREE.Vector2(2.1, -7.4),
new THREE.Vector2(0.2, -3.8),
new THREE.Vector2(0.2, -1.2),
new THREE.Vector2(1.5, 0.2),
new THREE.Vector2(-0.8, 2.4),
new THREE.Vector2(-0.1, 4.1),
new THREE.Vector2(-2.5, 8.1),
new THREE.Vector2(-3.3, 13),
];
const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => {
const palette = palettes[theme];
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-14, 14, 11, -11, 0.1, 60);
const cameraBase = new THREE.Vector3(15, 17, 18);
camera.position.copy(cameraBase);
camera.lookAt(0, 0, 0);
addSoftLights(scene, theme, 19);
const world = new THREE.Group();
scene.add(world);
const edgeMaterial = new THREE.LineBasicMaterial({
color: palette.edge,
transparent: true,
opacity: theme === 'dark' ? 0.48 : 0.56,
});
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(34, 34),
new THREE.MeshStandardMaterial({ color: palette.ground, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.32;
ground.receiveShadow = true;
world.add(ground);
const gridVertices: number[] = [];
for (let value = -17; value <= 17; value += 1) {
gridVertices.push(-17, -0.3, value, 17, -0.3, value);
gridVertices.push(value, -0.3, -17, value, -0.3, 17);
}
const gridGeometry = new THREE.BufferGeometry();
gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridVertices, 3));
world.add(
new THREE.LineSegments(
gridGeometry,
new THREE.LineBasicMaterial({
color: palette.edge,
transparent: true,
opacity: theme === 'dark' ? 0.18 : 0.2,
}),
),
);
const addPlate = (
group: THREE.Group,
x: number,
z: number,
width: number,
depth: number,
height = 0.42,
color = palette.ground,
) => {
const geometry = new THREE.BoxGeometry(width, height, depth);
const plate = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color, roughness: 0.94 }),
);
plate.position.set(x, height / 2 - 0.25, z);
plate.castShadow = true;
plate.receiveShadow = true;
plate.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 25), edgeMaterial));
group.add(plate);
return plate;
};
const primaryPlates = new THREE.Group();
const secondaryPlates = new THREE.Group();
const tertiaryPlates = new THREE.Group();
world.add(primaryPlates, secondaryPlates, tertiaryPlates);
[
[-7.5, -7.7, 9.5, 5.1], [-6.2, -2.7, 8.5, 4.2], [-6.5, 2.2, 8.7, 4.3],
[-7.1, 7.2, 9.2, 5.0], [7.4, -8.0, 9.4, 5.4], [6.2, -2.9, 8.0, 4.1],
[6.6, 2.0, 8.4, 4.2], [6.3, 7.0, 9.1, 5.0],
].forEach(([x, z, width, depth]) => addPlate(primaryPlates, x, z, width, depth));
[
[-11.7, -2.1, 2.5, 2.3], [-10.6, 4.0, 3.0, 2.5], [-5.0, -5.4, 2.5, 2.0],
[-5.3, 5.0, 2.7, 2.1], [10.5, -3.4, 3.0, 2.6], [9.2, 4.0, 2.7, 2.1],
].forEach(([x, z, width, depth]) =>
addPlate(secondaryPlates, x, z, width, depth, 0.5, theme === 'dark' ? 0x0b2f5f : 0xe6f0fe),
);
const riverGeometry = new THREE.ShapeGeometry(createRibbonShape(riverPath, 2.2));
const waterMaterial = new THREE.MeshStandardMaterial({
color: palette.water,
emissive: palette.water,
emissiveIntensity: theme === 'dark' ? 0.28 : 0.1,
roughness: 0.52,
});
const river = new THREE.Mesh(riverGeometry, waterMaterial);
river.rotation.x = -Math.PI / 2;
river.position.y = -0.23;
world.add(river);
const riverEdge = new THREE.LineSegments(new THREE.EdgesGeometry(riverGeometry), edgeMaterial);
riverEdge.rotation.copy(river.rotation);
riverEdge.position.y = -0.21;
world.add(riverEdge);
riverPath.slice(0, -1).forEach((start, index) => {
const end = riverPath[index + 1];
const direction = end.clone().sub(start);
const length = direction.length();
direction.normalize();
for (let distance = 1; distance < length; distance += 1.15) {
const at = start.clone().addScaledVector(direction, distance);
const seam = new THREE.Mesh(
new THREE.PlaneGeometry(2.08, 0.014),
new THREE.MeshBasicMaterial({ color: palette.edge, transparent: true, opacity: 0.35 }),
);
seam.rotation.x = -Math.PI / 2;
seam.rotation.z = Math.atan2(direction.y, direction.x) + Math.PI / 2;
seam.position.set(at.x, -0.2, at.y);
tertiaryPlates.add(seam);
}
});
const addPad = (group: THREE.Group, x: number, z: number, scale = 1) => {
addPlate(group, x, z, 3.4 * scale, 2.7 * scale, 0.45, theme === 'dark' ? 0x103f7f : 0xe6f0fe);
addPlate(group, x, z, 2.75 * scale, 2.05 * scale, 0.7, palette.accentSoft);
};
addPad(primaryPlates, -6.2, -6.4, 0.95);
addPad(primaryPlates, 5.7, -5.8, 1.08);
addPad(primaryPlates, -5.6, 1.6, 1.04);
addPad(primaryPlates, 5.9, 4.8, 0.84);
addPad(secondaryPlates, 10.7, -6.3, 0.82);
const treeData = [
[-8.5, -9.5, 0.8, 1, 0], [-7.2, -9.0, 1, 0, 0], [-7.8, -0.5, 0.85, 1, 0],
[-6.8, 0.0, 1.1, 0, 0], [-8.4, 1.2, 0.8, 0, 1], [-7.0, 8.7, 0.95, 0, 1],
[7.0, -9.2, 1, 0, 0], [8.2, -8.6, 0.8, 0, 0], [9.2, -8.0, 0.85, 1, 1],
[8.3, 4.0, 1.05, 0, 0], [9.4, 4.8, 0.82, 0, 1], [3.5, 8.7, 0.9, 0, 0],
[4.8, 9.4, 0.82, 1, 1], [-4.8, 9.2, 0.92, 0, 1],
] as const;
const animatedTrees: THREE.Group[] = [];
treeData.forEach(([x, z, scale, pill, secondary]) => {
const tree = createTree(palette, scale, pill === 1);
tree.position.set(x, 0.05, z);
(secondary ? secondaryPlates : primaryPlates).add(tree);
animatedTrees.push(tree);
});
return {
scene,
camera,
resize: (width, height) => {
const mobile = width < 640;
const tablet = width < 1024;
secondaryPlates.visible = !mobile;
tertiaryPlates.visible = !tablet;
world.scale.setScalar(mobile ? 0.82 : tablet ? 0.92 : 1);
fitOrthographicCamera(camera, width, height, mobile ? 23 : tablet ? 22 : 21.5);
},
update: (elapsed) => {
waterMaterial.emissiveIntensity = (theme === 'dark' ? 0.28 : 0.1) + Math.sin(elapsed * 0.8) * 0.025;
animatedTrees.forEach((tree, index) => {
tree.rotation.z = Math.sin(elapsed * 0.6 + index) * 0.005;
});
if (!reduceMotion) {
camera.position.x += (cameraBase.x + pointer.x * 0.22 - camera.position.x) * 0.028;
camera.position.z += (cameraBase.z - pointer.x * 0.18 - camera.position.z) * 0.028;
camera.lookAt(0, 0, 0);
}
},
};
};
export function AboutRiverScene() {
const { theme } = useTheme();
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} />;
}

View File

@@ -32,8 +32,20 @@
z-index: 0; z-index: 0;
} }
.aboutScene::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, var(--bg-page), transparent 12%, transparent 88%, var(--bg-page));
}
@media (max-width: 640px) { @media (max-width: 640px) {
.heroScene::after { .heroScene::after {
opacity: 0.62; opacity: 0.62;
} }
.aboutScene {
bottom: auto;
height: 640px;
}
} }

View File

@@ -58,6 +58,25 @@ export function fitOrthographicCamera(
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
export function createRibbonShape(points: THREE.Vector2[], width: number) {
const halfWidth = width / 2;
const offsetPoint = (index: number, direction: 1 | -1) => {
const current = points[index].clone();
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const tangent = next.clone().sub(previous).normalize();
return current.add(new THREE.Vector2(-tangent.y, tangent.x).multiplyScalar(halfWidth * direction));
};
const left = points.map((_, index) => offsetPoint(index, 1));
const right = points.map((_, index) => offsetPoint(index, -1)).reverse();
const shape = new THREE.Shape();
shape.moveTo(left[0].x, left[0].y);
[...left.slice(1), ...right].forEach((point) => shape.lineTo(point.x, point.y));
shape.closePath();
return shape;
}
export function createMesa( export function createMesa(
width: number, width: number,
height: number, height: number,