From ab2683c4764c47f20cab7d0babe5253690d5f7c6 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 6 Sep 2026 14:17:02 +0330 Subject: [PATCH] feat(about): implement 90-degree orthogonal isometric river with large-screen overflow and responsive timeline --- src/components/RiverJourney.module.css | 49 ++++++++----- src/components/RiverJourney.tsx | 8 +-- src/components/scenes/AboutRiverScene.tsx | 39 ++++++----- src/components/scenes/Scenes.module.css | 20 +++++- src/components/scenes/scenePrimitives.ts | 83 ++++++++++++++++++----- 5 files changed, 141 insertions(+), 58 deletions(-) diff --git a/src/components/RiverJourney.module.css b/src/components/RiverJourney.module.css index 0088352..f5359f7 100644 --- a/src/components/RiverJourney.module.css +++ b/src/components/RiverJourney.module.css @@ -1,7 +1,7 @@ .journeySection { position: relative; width: 100%; - height: 1050px; + height: 65.625rem; overflow: hidden; background: var(--bg-page); } @@ -87,11 +87,11 @@ 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; } +.step05 { top: 7.25rem; right: 2.875rem; } +.step04 { top: 20.875rem; right: 15.625rem; } +.step03 { top: 31.25rem; left: 48%; } +.step02 { top: 43.125rem; left: 25%; } +.step01 { top: 51.625rem; left: 4.375rem; } .stepNumber { flex: 0 0 auto; @@ -124,11 +124,11 @@ width: 14.375rem; } - .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; } + .step05 { top: 5.625rem; right: 2rem; } + .step04 { top: 18.75rem; right: 10%; } + .step03 { top: 30.625rem; left: 48%; } + .step02 { top: 43.125rem; left: 24%; } + .step01 { top: 53.125rem; left: 2rem; } } @media (max-width: 760px) { @@ -172,7 +172,7 @@ flex-direction: column; gap: var(--space-3); margin-top: 13.75rem; - padding-left: 1.25rem; + padding-left: 1.5rem; } .stepsList::before { @@ -180,8 +180,8 @@ position: absolute; top: 1.25rem; bottom: 1.25rem; - left: 0.3125rem; - width: 1px; + left: 0.5rem; + width: 2px; background: var(--border-strong); } @@ -203,18 +203,33 @@ content: ''; position: absolute; top: 1.375rem; - left: -1.25rem; - width: 0.5625rem; - height: 0.5625rem; + left: -1rem; + width: 0.625rem; + height: 0.625rem; + transform: translateX(-50%); border: 3px solid var(--bg-page); border-radius: 50%; background: var(--accent-default); } } +@media (min-width: 1441px) { + .journeySection { + overflow: visible; + } + + .journeySection::after { + left: 50%; + right: auto; + transform: translateX(-50%); + width: 100vw; + } +} + @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 f75cefe..2b302c0 100644 --- a/src/components/RiverJourney.tsx +++ b/src/components/RiverJourney.tsx @@ -5,11 +5,11 @@ 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.' }, + { number: '02', title: 'UI/UX Designer', description: 'Designed intuitive interfaces and user-centered experiences.' }, + { number: '03', title: 'Computer Science', description: 'Built strong foundations in algorithms and systems thinking.' }, + { number: '04', title: 'Software Engineer', description: 'Engineered scalable applications and delivered real-world solutions.' }, + { number: '05', title: 'Product Engineer', description: 'Merging product strategy with engineering to ship impactful products.' }, ]; export const RiverJourney: React.FC = () => { diff --git a/src/components/scenes/AboutRiverScene.tsx b/src/components/scenes/AboutRiverScene.tsx index 416ae75..af7b190 100644 --- a/src/components/scenes/AboutRiverScene.tsx +++ b/src/components/scenes/AboutRiverScene.tsx @@ -12,16 +12,15 @@ import { } from './scenePrimitives'; import styles from './Scenes.module.css'; +// Sharp 90-degree orthogonal isometric river path: +// Enters from bottom-left along the isometric Z axis, +// takes a sharp 90-degree orthogonal step across the isometric X axis at the center, +// and turns 90 degrees to continue along the isometric Z axis towards the top-right. 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), + new THREE.Vector2(-1.3, -16), + new THREE.Vector2(-1.3, 0), + new THREE.Vector2(1.3, 0), + new THREE.Vector2(1.3, 16), ]; const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => { @@ -42,7 +41,7 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => { }); const ground = new THREE.Mesh( - new THREE.PlaneGeometry(34, 34), + new THREE.PlaneGeometry(60, 60), new THREE.MeshStandardMaterial({ color: palette.ground, roughness: 1 }), ); ground.rotation.x = -Math.PI / 2; @@ -51,9 +50,9 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => { 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); + for (let value = -30; value <= 30; value += 1) { + gridVertices.push(-30, -0.3, value, 30, -0.3, value); + gridVertices.push(value, -0.3, -30, value, -0.3, 30); } const gridGeometry = new THREE.BufferGeometry(); gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridVertices, 3)); @@ -96,8 +95,8 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => { 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], + [-7.5, -7.7, 9.5, 5.1], [-6.6, -2.7, 8.5, 4.2], [-6.8, 2.2, 8.7, 4.3], + [-7.1, 7.2, 9.2, 5.0], [7.4, -8.0, 9.4, 5.4], [6.4, -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)); @@ -108,7 +107,7 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => { addPlate(secondaryPlates, x, z, width, depth, 0.5, theme === 'dark' ? 0x0b2f5f : 0xe6f0fe), ); - const riverGeometry = new THREE.ShapeGeometry(createRibbonShape(riverPath, 2.2)); + const riverGeometry = new THREE.ShapeGeometry(createRibbonShape(riverPath, 1.8)); const waterMaterial = new THREE.MeshStandardMaterial({ color: palette.water, emissive: palette.water, @@ -130,15 +129,15 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => { const direction = end.clone().sub(start); const length = direction.length(); direction.normalize(); - for (let distance = 1; distance < length; distance += 1.15) { + for (let distance = 1.3; distance < length - 0.7; distance += 1.35) { 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 }), + new THREE.PlaneGeometry(1.72, 0.016), + new THREE.MeshBasicMaterial({ color: palette.edge, transparent: true, opacity: 0.32 }), ); 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); + seam.position.set(at.x, -0.21, -at.y); tertiaryPlates.add(seam); } }); diff --git a/src/components/scenes/Scenes.module.css b/src/components/scenes/Scenes.module.css index fec6e6a..58f4c46 100644 --- a/src/components/scenes/Scenes.module.css +++ b/src/components/scenes/Scenes.module.css @@ -50,10 +50,28 @@ .aboutScene { bottom: auto; - height: 640px; + height: 40rem; } .aboutScene canvas { opacity: 0.68; } } + +@media (min-width: 1441px) { + .aboutScene { + overflow: visible; + left: 50%; + right: auto; + transform: translateX(-50%); + width: 100vw; + } + + .aboutScene::after { + left: 50%; + right: auto; + transform: translateX(-50%); + width: 100vw; + } +} + diff --git a/src/components/scenes/scenePrimitives.ts b/src/components/scenes/scenePrimitives.ts index 6b0c0c8..ec44f50 100644 --- a/src/components/scenes/scenePrimitives.ts +++ b/src/components/scenes/scenePrimitives.ts @@ -60,19 +60,52 @@ export function fitOrthographicCamera( 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 n = points.length; + if (n < 2) return new THREE.Shape(); + + const left: THREE.Vector2[] = []; + const right: THREE.Vector2[] = []; + + for (let i = 0; i < n; i++) { + let normal: THREE.Vector2; + let miterScale = 1; + + if (i === 0) { + const d = points[1].clone().sub(points[0]).normalize(); + normal = new THREE.Vector2(-d.y, d.x); + } else if (i === n - 1) { + const d = points[n - 1].clone().sub(points[n - 2]).normalize(); + normal = new THREE.Vector2(-d.y, d.x); + } else { + const d1 = points[i].clone().sub(points[i - 1]).normalize(); + const d2 = points[i + 1].clone().sub(points[i]).normalize(); + const n1 = new THREE.Vector2(-d1.y, d1.x); + const n2 = new THREE.Vector2(-d2.y, d2.x); + const miter = n1.clone().add(n2); + const len = miter.length(); + if (len > 0.0001) { + miter.normalize(); + const dot = Math.max(miter.dot(n1), 0.25); + miterScale = Math.min(1 / dot, 2.5); + normal = miter; + } else { + normal = n1; + } + } + + const offset = normal.clone().multiplyScalar(halfWidth * miterScale); + left.push(points[i].clone().add(offset)); + right.push(points[i].clone().sub(offset)); + } - 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)); + for (let i = 1; i < left.length; i++) { + shape.lineTo(left[i].x, left[i].y); + } + for (let i = right.length - 1; i >= 0; i--) { + shape.lineTo(right[i].x, right[i].y); + } shape.closePath(); return shape; } @@ -92,7 +125,7 @@ export function createMesa( halfWidth - inset, height, halfDepth - inset, -halfWidth + inset, height, halfDepth - inset, ]; const indices = [ - 4, 5, 6, 4, 6, 7, + 4, 6, 5, 4, 7, 6, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, @@ -101,15 +134,33 @@ export function createMesa( const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); + geometry.clearGroups(); + geometry.addGroup(0, 6, 0); + geometry.addGroup(6, 6, 1); + geometry.addGroup(12, 6, 2); + geometry.addGroup(18, 6, 1); + geometry.addGroup(24, 6, 2); geometry.computeVertexNormals(); const mesa = new THREE.Mesh( geometry, - new THREE.MeshStandardMaterial({ - color: palette.mountain, - flatShading: true, - roughness: 0.9, - }), + [ + new THREE.MeshStandardMaterial({ + color: palette.mountainLight, + flatShading: true, + roughness: 0.9, + }), + new THREE.MeshStandardMaterial({ + color: palette.mountain, + flatShading: true, + roughness: 0.92, + }), + new THREE.MeshStandardMaterial({ + color: palette.mountainDark, + flatShading: true, + roughness: 0.94, + }), + ], ); mesa.castShadow = true; mesa.receiveShadow = true;