feat: build responsive Three.js river journey
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section id="about" className={styles.journeySection}>
|
||||
<section id="about" className={styles.journeySection} aria-labelledby="about-title">
|
||||
<AboutRiverScene />
|
||||
{/* Intro Header & Description on the Left */}
|
||||
<div className={styles.introBlock}>
|
||||
<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} />
|
||||
<p className={styles.description}>
|
||||
I began as a digital artist, designing visual stories. Curiosity turned
|
||||
@@ -20,97 +29,17 @@ export const RiverJourney: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 5 Freely Positioned Milestones along the River */}
|
||||
|
||||
{/* Step 05: Product Engineer */}
|
||||
<div className={styles.step05Graphic}>
|
||||
<Image
|
||||
src="/assets/step-05-product-engineer.png"
|
||||
alt="Product Engineer Milestone"
|
||||
width={180}
|
||||
height={240}
|
||||
className={styles.islandImage}
|
||||
/>
|
||||
</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>
|
||||
<ol className={styles.stepsList} aria-label="Career journey">
|
||||
{steps.map((step) => (
|
||||
<li key={step.number} className={`${styles.stepCard} ${styles[`step${step.number}`]}`}>
|
||||
<span className={styles.stepNumber}>{step.number}</span>
|
||||
<div>
|
||||
<h3 className={styles.stepTitle}>{step.title}</h3>
|
||||
<p className={styles.stepDesc}>{step.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
199
src/components/scenes/AboutRiverScene.tsx
Normal file
199
src/components/scenes/AboutRiverScene.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user