From f6228f8507323f0120677a9730762fed25c63562 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 5 Sep 2026 17:22:12 +0800 Subject: [PATCH] feat: build responsive Three.js river journey --- src/components/RiverJourney.module.css | 288 +++++++++++++--------- src/components/RiverJourney.tsx | 117 ++------- src/components/scenes/AboutRiverScene.tsx | 199 +++++++++++++++ src/components/scenes/Scenes.module.css | 12 + src/components/scenes/scenePrimitives.ts | 19 ++ 5 files changed, 420 insertions(+), 215 deletions(-) create mode 100644 src/components/scenes/AboutRiverScene.tsx diff --git a/src/components/RiverJourney.module.css b/src/components/RiverJourney.module.css index f76d81c..fdb833e 100644 --- a/src/components/RiverJourney.module.css +++ b/src/components/RiverJourney.module.css @@ -2,171 +2,217 @@ position: relative; width: 100%; 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 { position: absolute; - left: 60px; - top: 170px; - width: 380px; - z-index: 5; + top: 112px; + left: var(--layout-page-gutter); + z-index: 3; + 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 { display: block; + margin-bottom: var(--space-2); + color: var(--text-secondary); font-size: var(--font-size-label-small); + font-weight: 700; line-height: var(--line-height-label-small); letter-spacing: var(--letter-spacing-eyebrow); text-transform: uppercase; - font-weight: 700; - color: var(--text-secondary); - margin-bottom: 8px; } .heading { - font-size: var(--font-size-h2); - line-height: var(--line-height-h2); - font-weight: 700; + margin-bottom: var(--space-3); color: var(--text-primary); - margin-bottom: 12px; + font-size: var(--font-size-h2); + font-weight: 700; + line-height: var(--line-height-h2); } .underlineBar { width: 120px; height: 3px; - background-color: var(--accent-default); + margin-bottom: var(--space-5); border-radius: var(--radius-pill); - margin-bottom: 24px; + background: var(--accent-default); } .description { - font-size: var(--font-size-body-large); - line-height: var(--line-height-body-large); color: var(--text-secondary); + font-size: var(--font-size-body-large); 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 { - font-size: 20px; - font-weight: 700; + flex: 0 0 auto; color: var(--accent-default); - margin-bottom: 4px; + font-size: 20px; + font-weight: 800; + line-height: 28px; } .stepTitle { - font-size: var(--font-size-h3); - line-height: var(--line-height-h3); - font-weight: 700; + margin-bottom: var(--space-1); color: var(--text-primary); - margin-bottom: 6px; + font-size: var(--font-size-h3); + font-weight: 700; + line-height: var(--line-height-h3); } .stepDesc { + color: var(--text-secondary); font-size: var(--font-size-body-small); line-height: var(--line-height-body-small); - color: var(--text-secondary); } -.islandImage { - object-fit: contain; - filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.08)); - transition: transform 0.3s ease; +@media (max-width: 1024px) { + .introBlock { + top: 80px; + } + + .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 { - transform: translateY(-4px) scale(1.02); +@media (max-width: 760px) { + .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 */ - -/* Step 05: Product Engineer */ -.step05Graphic { - 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; +@media (hover: hover) and (pointer: fine) { + .stepCard:hover { + border-color: var(--accent-default); + transform: translateY(-2px); + } } diff --git a/src/components/RiverJourney.tsx b/src/components/RiverJourney.tsx index f82ef88..f75cefe 100644 --- a/src/components/RiverJourney.tsx +++ b/src/components/RiverJourney.tsx @@ -1,16 +1,25 @@ 'use client'; import React from 'react'; -import Image from 'next/image'; +import { AboutRiverScene } from './scenes/AboutRiverScene'; 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 = () => { return ( -
+
+ {/* Intro Header & Description on the Left */}
ABOUT ME -

From Pixel to Product

+

From Pixel to Product

I began as a digital artist, designing visual stories. Curiosity turned @@ -20,97 +29,17 @@ export const RiverJourney: React.FC = () => {

- {/* 5 Freely Positioned Milestones along the River */} - - {/* Step 05: Product Engineer */} -
- Product Engineer Milestone -
-
-
05
-

Product Engineer

-

- Merging product strategy with engineering to ship impactful products. -

-
- - {/* Step 04: Software Engineer */} -
-
04
-

Software Engineer

-

- Engineered scalable applications and delivered real-world solutions. -

-
-
- Software Engineer Milestone -
- - {/* Step 03: Computer Science */} -
-
03
-

Computer Science

-

- Built strong foundations in algorithms and systems thinking. -

-
-
- Computer Science Milestone -
- - {/* Step 02: UI/UX Designer */} -
-
02
-

UI/UX Designer

-

- Designed Intuitive interfaces and user-centered experiences. -

-
-
- UI/UX Designer Milestone -
- - {/* Step 01: Digital Artist */} -
-
01
-

Digital Artist

-

- Explored visual design, illustration and storytelling. -

-
-
- Digital Artist Milestone -
+
    + {steps.map((step) => ( +
  1. + {step.number} +
    +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
); }; diff --git a/src/components/scenes/AboutRiverScene.tsx b/src/components/scenes/AboutRiverScene.tsx new file mode 100644 index 0000000..416ae75 --- /dev/null +++ b/src/components/scenes/AboutRiverScene.tsx @@ -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 ; +} diff --git a/src/components/scenes/Scenes.module.css b/src/components/scenes/Scenes.module.css index d03fce9..f19740c 100644 --- a/src/components/scenes/Scenes.module.css +++ b/src/components/scenes/Scenes.module.css @@ -32,8 +32,20 @@ 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) { .heroScene::after { opacity: 0.62; } + + .aboutScene { + bottom: auto; + height: 640px; + } } diff --git a/src/components/scenes/scenePrimitives.ts b/src/components/scenes/scenePrimitives.ts index 590d856..6b0c0c8 100644 --- a/src/components/scenes/scenePrimitives.ts +++ b/src/components/scenes/scenePrimitives.ts @@ -58,6 +58,25 @@ export function fitOrthographicCamera( 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( width: number, height: number,