feat(footer): add isometric Three.js contact scene
This commit is contained in:
40
src/components/footerContacts.ts
Normal file
40
src/components/footerContacts.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type FooterContactId = 'phone' | 'linkedin' | 'gitea' | 'telegram';
|
||||||
|
|
||||||
|
export interface FooterContact {
|
||||||
|
id: FooterContactId;
|
||||||
|
value: string;
|
||||||
|
href: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
external: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const footerContacts: readonly FooterContact[] = [
|
||||||
|
{
|
||||||
|
id: 'phone',
|
||||||
|
value: '09388263077',
|
||||||
|
href: 'tel:+989388263077',
|
||||||
|
ariaLabel: 'Call 09388263077',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linkedin',
|
||||||
|
value: '@amiirkhl',
|
||||||
|
href: 'https://linkedin.com/in/amiirkhl',
|
||||||
|
ariaLabel: 'LinkedIn profile',
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gitea',
|
||||||
|
value: 'git.amiirkhl.ir',
|
||||||
|
href: 'https://git.amiirkhl.ir',
|
||||||
|
ariaLabel: 'Personal Git server',
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'telegram',
|
||||||
|
value: '@amiirkhl',
|
||||||
|
href: 'https://t.me/amiirkhl',
|
||||||
|
ariaLabel: 'Telegram profile',
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
600
src/components/scenes/FooterScene.tsx
Normal file
600
src/components/scenes/FooterScene.tsx
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useTheme } from '@/context/ThemeContext';
|
||||||
|
import {
|
||||||
|
footerContacts,
|
||||||
|
type FooterContact,
|
||||||
|
type FooterContactId,
|
||||||
|
} from '../footerContacts';
|
||||||
|
import { ThreeCanvas, type SceneFactory } from './ThreeCanvas';
|
||||||
|
import {
|
||||||
|
addSoftLights,
|
||||||
|
createMesa,
|
||||||
|
createTree,
|
||||||
|
fitOrthographicCamera,
|
||||||
|
palettes,
|
||||||
|
type ScenePalette,
|
||||||
|
} from './scenePrimitives';
|
||||||
|
import styles from './Scenes.module.css';
|
||||||
|
|
||||||
|
const DESKTOP_MARKERS: Record<FooterContactId, [number, number]> = {
|
||||||
|
phone: [-7.2, 0.4],
|
||||||
|
linkedin: [-3.8, -0.45],
|
||||||
|
gitea: [3.8, -0.45],
|
||||||
|
telegram: [7.2, 0.4],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOBILE_MARKERS: Record<FooterContactId, [number, number]> = {
|
||||||
|
phone: [-4.25, 0.15],
|
||||||
|
linkedin: [-1.55, -0.85],
|
||||||
|
gitea: [1.55, -0.85],
|
||||||
|
telegram: [4.25, 0.15],
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorToCss = (color: number) => `#${color.toString(16).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
function roundedRect(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x + radius, y);
|
||||||
|
context.lineTo(x + width - radius, y);
|
||||||
|
context.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
context.lineTo(x + width, y + height - radius);
|
||||||
|
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
context.lineTo(x + radius, y + height);
|
||||||
|
context.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
context.lineTo(x, y + radius);
|
||||||
|
context.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkerIcon(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
id: FooterContactId,
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.lineCap = 'round';
|
||||||
|
context.lineJoin = 'round';
|
||||||
|
|
||||||
|
if (id === 'linkedin') {
|
||||||
|
context.font = '700 74px Arial, sans-serif';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText('in', 96, 103);
|
||||||
|
} else if (id === 'telegram') {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(48, 93);
|
||||||
|
context.lineTo(145, 50);
|
||||||
|
context.lineTo(124, 143);
|
||||||
|
context.lineTo(91, 112);
|
||||||
|
context.lineTo(72, 132);
|
||||||
|
context.lineTo(76, 103);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = '#f8faff';
|
||||||
|
context.lineWidth = 5;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(78, 103);
|
||||||
|
context.lineTo(124, 72);
|
||||||
|
context.stroke();
|
||||||
|
} else if (id === 'gitea') {
|
||||||
|
context.lineWidth = 10;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(96, 94, 39, Math.PI * 0.1, Math.PI * 1.9);
|
||||||
|
context.stroke();
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(75, 94);
|
||||||
|
context.lineTo(96, 73);
|
||||||
|
context.lineTo(119, 98);
|
||||||
|
context.stroke();
|
||||||
|
[[75, 94], [96, 73], [119, 98]].forEach(([nodeX, nodeY]) => {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(nodeX, nodeY, 8, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
context.lineWidth = 16;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(64, 57);
|
||||||
|
context.bezierCurveTo(55, 76, 67, 111, 89, 128);
|
||||||
|
context.bezierCurveTo(105, 140, 128, 143, 140, 128);
|
||||||
|
context.stroke();
|
||||||
|
context.lineWidth = 20;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(63, 58);
|
||||||
|
context.lineTo(77, 78);
|
||||||
|
context.moveTo(123, 115);
|
||||||
|
context.lineTo(141, 128);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMarkerTexture(contact: FooterContact, palette: ScenePalette) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 192;
|
||||||
|
canvas.height = 192;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return new THREE.CanvasTexture(canvas);
|
||||||
|
|
||||||
|
roundedRect(context, 14, 14, 164, 164, 28);
|
||||||
|
context.fillStyle = colorToCss(palette.ground);
|
||||||
|
context.fill();
|
||||||
|
context.lineWidth = 6;
|
||||||
|
context.strokeStyle = colorToCss(palette.accent);
|
||||||
|
context.stroke();
|
||||||
|
drawMarkerIcon(context, contact.id, colorToCss(palette.edge));
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCelestialTexture(theme: 'light' | 'dark') {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 192;
|
||||||
|
canvas.height = 192;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return new THREE.CanvasTexture(canvas);
|
||||||
|
|
||||||
|
context.translate(96, 96);
|
||||||
|
context.fillStyle = theme === 'light' ? '#ffe66d' : '#fffdf0';
|
||||||
|
if (theme === 'light') {
|
||||||
|
for (let index = 0; index < 12; index += 1) {
|
||||||
|
context.save();
|
||||||
|
context.rotate((index / 12) * Math.PI * 2);
|
||||||
|
context.fillRect(-4, -78, 8, 24);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(0, 0, 41, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
} else {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(0, 0, 51, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.globalCompositeOperation = 'destination-out';
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(25, -17, 51, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFooterScene: SceneFactory = ({ renderer, pointer, theme, reduceMotion }) => {
|
||||||
|
const palette = palettes[theme];
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.OrthographicCamera(-16, 16, 8, -8, 0.1, 80);
|
||||||
|
const cameraBase = new THREE.Vector3(15, 15, 18);
|
||||||
|
camera.position.copy(cameraBase);
|
||||||
|
camera.lookAt(0, 0.5, 0);
|
||||||
|
addSoftLights(scene, theme, 22);
|
||||||
|
|
||||||
|
const world = new THREE.Group();
|
||||||
|
world.position.y = -1.55;
|
||||||
|
scene.add(world);
|
||||||
|
|
||||||
|
const edgeMaterial = new THREE.LineBasicMaterial({
|
||||||
|
color: palette.edge,
|
||||||
|
transparent: true,
|
||||||
|
opacity: theme === 'dark' ? 0.58 : 0.5,
|
||||||
|
});
|
||||||
|
const groundMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: palette.ground,
|
||||||
|
roughness: 0.95,
|
||||||
|
});
|
||||||
|
const softGroundMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: theme === 'dark' ? 0x061943 : 0xffffff,
|
||||||
|
roughness: 0.95,
|
||||||
|
});
|
||||||
|
const accentMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: palette.accent,
|
||||||
|
emissive: palette.accent,
|
||||||
|
emissiveIntensity: theme === 'dark' ? 0.24 : 0.08,
|
||||||
|
roughness: 0.72,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridVertices: number[] = [];
|
||||||
|
for (let value = -24; value <= 24; value += 1) {
|
||||||
|
gridVertices.push(-24, -0.34, value, 24, -0.34, value);
|
||||||
|
gridVertices.push(value, -0.34, -24, value, -0.34, 24);
|
||||||
|
}
|
||||||
|
const gridGeometry = new THREE.BufferGeometry();
|
||||||
|
gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridVertices, 3));
|
||||||
|
const grid = new THREE.LineSegments(
|
||||||
|
gridGeometry,
|
||||||
|
new THREE.ShaderMaterial({
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
uniforms: {
|
||||||
|
gridColor: { value: new THREE.Color(palette.edge) },
|
||||||
|
gridOpacity: { value: theme === 'dark' ? 0.2 : 0.17 },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec3 gridPosition;
|
||||||
|
void main() {
|
||||||
|
gridPosition = position;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 gridColor;
|
||||||
|
uniform float gridOpacity;
|
||||||
|
varying vec3 gridPosition;
|
||||||
|
void main() {
|
||||||
|
float distanceFromCenter = length(gridPosition.xz);
|
||||||
|
float fade = 1.0 - smoothstep(7.0, 24.0, distanceFromCenter);
|
||||||
|
gl_FragColor = vec4(gridColor, gridOpacity * fade);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
world.add(grid);
|
||||||
|
|
||||||
|
const shadowGround = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(48, 30),
|
||||||
|
new THREE.ShadowMaterial({
|
||||||
|
color: 0x000000,
|
||||||
|
opacity: theme === 'dark' ? 0.22 : 0.07,
|
||||||
|
transparent: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
shadowGround.rotation.x = -Math.PI / 2;
|
||||||
|
shadowGround.position.y = -0.36;
|
||||||
|
shadowGround.receiveShadow = true;
|
||||||
|
world.add(shadowGround);
|
||||||
|
|
||||||
|
const addPlate = (
|
||||||
|
parent: THREE.Group,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
width: number,
|
||||||
|
depth: number,
|
||||||
|
height = 0.42,
|
||||||
|
material: THREE.Material = groundMaterial,
|
||||||
|
) => {
|
||||||
|
const geometry = new THREE.BoxGeometry(width, height, depth);
|
||||||
|
const plate = new THREE.Mesh(geometry, material);
|
||||||
|
plate.position.set(x, height / 2 - 0.28, z);
|
||||||
|
plate.castShadow = true;
|
||||||
|
plate.receiveShadow = true;
|
||||||
|
plate.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 24), edgeMaterial));
|
||||||
|
parent.add(plate);
|
||||||
|
return plate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const plates = new THREE.Group();
|
||||||
|
world.add(plates);
|
||||||
|
[
|
||||||
|
[-10.2, 1.2, 7.6, 6.2],
|
||||||
|
[-5.2, 0.5, 6.5, 5.6],
|
||||||
|
[0, 0.85, 7.2, 5.8],
|
||||||
|
[5.2, 0.5, 6.5, 5.6],
|
||||||
|
[10.2, 1.2, 7.6, 6.2],
|
||||||
|
].forEach(([x, z, width, depth]) => addPlate(plates, x, z, width, depth));
|
||||||
|
addPlate(plates, 0, 0.75, 5.2, 4.1, 0.68, softGroundMaterial);
|
||||||
|
addPlate(plates, 0, 0.75, 4.25, 3.2, 0.96, groundMaterial);
|
||||||
|
|
||||||
|
const outerLandscape = new THREE.Group();
|
||||||
|
world.add(outerLandscape);
|
||||||
|
[
|
||||||
|
[-13.2, 2.2, 3.7, 4.8, 3.6],
|
||||||
|
[-10.7, 3, 2.7, 3.4, 2.4],
|
||||||
|
[-14.6, -0.6, 2.8, 3.2, 2.2],
|
||||||
|
[13.2, 2.2, 3.7, 4.8, 3.6],
|
||||||
|
[10.7, 3, 2.7, 3.4, 2.4],
|
||||||
|
[14.6, -0.6, 2.8, 3.2, 2.2],
|
||||||
|
].forEach(([x, z, width, height, depth]) => {
|
||||||
|
const mesa = createMesa(width, height, depth, palette);
|
||||||
|
mesa.position.set(x, 0, z);
|
||||||
|
outerLandscape.add(mesa);
|
||||||
|
});
|
||||||
|
|
||||||
|
const trees = new THREE.Group();
|
||||||
|
const secondaryTrees = new THREE.Group();
|
||||||
|
world.add(trees, secondaryTrees);
|
||||||
|
[
|
||||||
|
[-11.3, -1.2, 0.72], [-9.1, 2.8, 0.62], [-7.9, -1.5, 0.6],
|
||||||
|
[11.3, -1.2, 0.72], [9.1, 2.8, 0.62], [7.9, -1.5, 0.6],
|
||||||
|
].forEach(([x, z, scale]) => {
|
||||||
|
const tree = createTree(palette, scale);
|
||||||
|
tree.position.set(x, 0.02, z);
|
||||||
|
trees.add(tree);
|
||||||
|
});
|
||||||
|
[
|
||||||
|
[-13.8, 4.4, 0.55], [-6.5, 2.9, 0.5],
|
||||||
|
[13.8, 4.4, 0.55], [6.5, 2.9, 0.5],
|
||||||
|
].forEach(([x, z, scale]) => {
|
||||||
|
const tree = createTree(palette, scale);
|
||||||
|
tree.position.set(x, 0.02, z);
|
||||||
|
secondaryTrees.add(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
const workstation = new THREE.Group();
|
||||||
|
workstation.position.set(0, 0.42, 0.75);
|
||||||
|
world.add(workstation);
|
||||||
|
|
||||||
|
const monitor = new THREE.Group();
|
||||||
|
monitor.rotation.y = Math.PI / 4;
|
||||||
|
monitor.position.set(-0.65, 0.75, 0.1);
|
||||||
|
const screenGeometry = new THREE.BoxGeometry(2.05, 1.25, 0.12);
|
||||||
|
const screen = new THREE.Mesh(screenGeometry, accentMaterial);
|
||||||
|
screen.castShadow = true;
|
||||||
|
screen.add(new THREE.LineSegments(new THREE.EdgesGeometry(screenGeometry), edgeMaterial));
|
||||||
|
screen.position.y = 1.04;
|
||||||
|
const monitorStand = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.1, 0.14, 0.8, 8),
|
||||||
|
new THREE.MeshStandardMaterial({ color: palette.edge, roughness: 0.84 }),
|
||||||
|
);
|
||||||
|
monitorStand.position.y = 0.4;
|
||||||
|
const monitorFoot = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(0.95, 0.1, 0.55),
|
||||||
|
new THREE.MeshStandardMaterial({ color: palette.mountainLight, roughness: 0.86 }),
|
||||||
|
);
|
||||||
|
monitor.add(screen, monitorStand, monitorFoot);
|
||||||
|
workstation.add(monitor);
|
||||||
|
|
||||||
|
const pencil = new THREE.Group();
|
||||||
|
const pencilBody = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.16, 0.16, 2.6, 6),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: palette.mountain,
|
||||||
|
flatShading: true,
|
||||||
|
roughness: 0.86,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
pencilBody.castShadow = true;
|
||||||
|
const pencilTip = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.17, 0.48, 6),
|
||||||
|
new THREE.MeshStandardMaterial({ color: palette.snow, flatShading: true, roughness: 0.92 }),
|
||||||
|
);
|
||||||
|
pencilTip.position.y = 1.54;
|
||||||
|
pencil.add(pencilBody, pencilTip);
|
||||||
|
pencil.position.set(0.65, 1.3, 0.15);
|
||||||
|
workstation.add(pencil);
|
||||||
|
|
||||||
|
const productGeometry = new THREE.BoxGeometry(1.15, 1.15, 1.15);
|
||||||
|
const product = new THREE.Mesh(
|
||||||
|
productGeometry,
|
||||||
|
new THREE.MeshStandardMaterial({ color: palette.mountain, roughness: 0.86 }),
|
||||||
|
);
|
||||||
|
product.position.set(1.65, 0.58, 0.25);
|
||||||
|
product.castShadow = true;
|
||||||
|
product.add(new THREE.LineSegments(new THREE.EdgesGeometry(productGeometry), edgeMaterial));
|
||||||
|
workstation.add(product);
|
||||||
|
|
||||||
|
const connectorMaterial = new THREE.MeshBasicMaterial({ color: palette.accent });
|
||||||
|
const desktopConnectors = new THREE.Group();
|
||||||
|
const mobileConnectors = new THREE.Group();
|
||||||
|
world.add(desktopConnectors, mobileConnectors);
|
||||||
|
|
||||||
|
const addConnectorSegment = (
|
||||||
|
parent: THREE.Group,
|
||||||
|
from: THREE.Vector3,
|
||||||
|
to: THREE.Vector3,
|
||||||
|
) => {
|
||||||
|
const direction = to.clone().sub(from);
|
||||||
|
const geometry = new THREE.CylinderGeometry(0.045, 0.045, direction.length(), 6);
|
||||||
|
const segment = new THREE.Mesh(geometry, connectorMaterial);
|
||||||
|
segment.position.copy(from).add(to).multiplyScalar(0.5);
|
||||||
|
segment.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize());
|
||||||
|
parent.add(segment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addConnectors = (
|
||||||
|
parent: THREE.Group,
|
||||||
|
positions: Record<FooterContactId, [number, number]>,
|
||||||
|
) => {
|
||||||
|
footerContacts.forEach(({ id }) => {
|
||||||
|
const [x, z] = positions[id];
|
||||||
|
const elbowX = x * 0.52;
|
||||||
|
addConnectorSegment(
|
||||||
|
parent,
|
||||||
|
new THREE.Vector3(Math.sign(x) * 2.1, 0.28, 0.72),
|
||||||
|
new THREE.Vector3(elbowX, 0.28, z - 0.85),
|
||||||
|
);
|
||||||
|
addConnectorSegment(
|
||||||
|
parent,
|
||||||
|
new THREE.Vector3(elbowX, 0.28, z - 0.85),
|
||||||
|
new THREE.Vector3(x, 0.28, z),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
addConnectors(desktopConnectors, DESKTOP_MARKERS);
|
||||||
|
addConnectors(mobileConnectors, MOBILE_MARKERS);
|
||||||
|
mobileConnectors.visible = false;
|
||||||
|
|
||||||
|
const markerGroups = new Map<FooterContactId, THREE.Group>();
|
||||||
|
const markerTargets: THREE.Sprite[] = [];
|
||||||
|
footerContacts.forEach((contact) => {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const [x, z] = DESKTOP_MARKERS[contact.id];
|
||||||
|
group.position.set(x, 0.18, z);
|
||||||
|
group.userData.baseY = group.position.y;
|
||||||
|
group.userData.hovered = false;
|
||||||
|
|
||||||
|
const pole = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.065, 0.085, 1.55, 8),
|
||||||
|
new THREE.MeshStandardMaterial({ color: palette.edge, roughness: 0.86 }),
|
||||||
|
);
|
||||||
|
pole.position.y = 0.77;
|
||||||
|
pole.castShadow = true;
|
||||||
|
const foot = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.32, 0.4, 0.12, 16),
|
||||||
|
new THREE.MeshStandardMaterial({ color: palette.accentSoft, roughness: 0.8 }),
|
||||||
|
);
|
||||||
|
foot.position.y = 0.06;
|
||||||
|
const marker = new THREE.Sprite(
|
||||||
|
new THREE.SpriteMaterial({
|
||||||
|
map: createMarkerTexture(contact, palette),
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
marker.position.y = 1.82;
|
||||||
|
marker.scale.setScalar(1.55);
|
||||||
|
marker.userData.contactId = contact.id;
|
||||||
|
group.add(pole, foot, marker);
|
||||||
|
world.add(group);
|
||||||
|
markerGroups.set(contact.id, group);
|
||||||
|
markerTargets.push(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
const celestial = new THREE.Sprite(
|
||||||
|
new THREE.SpriteMaterial({
|
||||||
|
map: createCelestialTexture(theme),
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
celestial.position.set(9.8, 7.2, 0);
|
||||||
|
celestial.scale.setScalar(2.3);
|
||||||
|
scene.add(celestial);
|
||||||
|
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const hitPointer = new THREE.Vector2();
|
||||||
|
let hoveredMarker: THREE.Sprite | null = null;
|
||||||
|
let pointerDown: { id: FooterContactId; x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
const getMarkerAt = (event: PointerEvent) => {
|
||||||
|
const bounds = renderer.domElement.getBoundingClientRect();
|
||||||
|
if (!bounds.width || !bounds.height) return null;
|
||||||
|
hitPointer.set(
|
||||||
|
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
||||||
|
-(((event.clientY - bounds.top) / bounds.height) * 2 - 1),
|
||||||
|
);
|
||||||
|
raycaster.setFromCamera(hitPointer, camera);
|
||||||
|
return (raycaster.intersectObjects(markerTargets, false)[0]?.object as THREE.Sprite | undefined) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNow = () => renderer.render(scene, camera);
|
||||||
|
const setHoveredMarker = (next: THREE.Sprite | null) => {
|
||||||
|
if (hoveredMarker === next) return;
|
||||||
|
if (hoveredMarker) {
|
||||||
|
const previousId = hoveredMarker.userData.contactId as FooterContactId;
|
||||||
|
const previousGroup = markerGroups.get(previousId);
|
||||||
|
if (previousGroup) previousGroup.userData.hovered = false;
|
||||||
|
}
|
||||||
|
hoveredMarker = next;
|
||||||
|
if (hoveredMarker) {
|
||||||
|
const nextId = hoveredMarker.userData.contactId as FooterContactId;
|
||||||
|
const nextGroup = markerGroups.get(nextId);
|
||||||
|
if (nextGroup) nextGroup.userData.hovered = true;
|
||||||
|
}
|
||||||
|
renderer.domElement.style.cursor = hoveredMarker ? 'pointer' : '';
|
||||||
|
if (reduceMotion) renderNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent) => setHoveredMarker(getMarkerAt(event));
|
||||||
|
const onPointerLeave = () => {
|
||||||
|
pointerDown = null;
|
||||||
|
setHoveredMarker(null);
|
||||||
|
};
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
|
const marker = getMarkerAt(event);
|
||||||
|
if (!marker) return;
|
||||||
|
pointerDown = {
|
||||||
|
id: marker.userData.contactId as FooterContactId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const onPointerUp = (event: PointerEvent) => {
|
||||||
|
const marker = getMarkerAt(event);
|
||||||
|
const activePointer = pointerDown;
|
||||||
|
pointerDown = null;
|
||||||
|
if (!marker || !activePointer) return;
|
||||||
|
const markerId = marker.userData.contactId as FooterContactId;
|
||||||
|
const movement = Math.hypot(event.clientX - activePointer.x, event.clientY - activePointer.y);
|
||||||
|
if (markerId !== activePointer.id || movement > 8) return;
|
||||||
|
const contact = footerContacts.find((item) => item.id === markerId);
|
||||||
|
if (!contact) return;
|
||||||
|
if (contact.external) {
|
||||||
|
window.open(contact.href, '_blank', 'noopener,noreferrer');
|
||||||
|
} else {
|
||||||
|
window.location.href = contact.href;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPointerCancel = () => {
|
||||||
|
pointerDown = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
canvas.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||||
|
canvas.addEventListener('pointerleave', onPointerLeave);
|
||||||
|
canvas.addEventListener('pointerdown', onPointerDown, { passive: true });
|
||||||
|
canvas.addEventListener('pointerup', onPointerUp, { passive: true });
|
||||||
|
canvas.addEventListener('pointercancel', onPointerCancel);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
resize: (width, height) => {
|
||||||
|
const mobile = width < 640;
|
||||||
|
const tablet = width < 1024;
|
||||||
|
outerLandscape.visible = !mobile;
|
||||||
|
secondaryTrees.visible = !tablet;
|
||||||
|
trees.visible = !mobile;
|
||||||
|
celestial.visible = !mobile;
|
||||||
|
desktopConnectors.visible = !mobile;
|
||||||
|
mobileConnectors.visible = mobile;
|
||||||
|
grid.scale.setScalar(mobile ? 0.62 : tablet ? 0.84 : 1);
|
||||||
|
world.scale.setScalar(mobile ? 0.82 : tablet ? 0.92 : 1);
|
||||||
|
world.position.y = mobile ? -1.05 : tablet ? -1.35 : -1.55;
|
||||||
|
const positions = mobile ? MOBILE_MARKERS : DESKTOP_MARKERS;
|
||||||
|
markerGroups.forEach((group, id) => {
|
||||||
|
const [x, z] = positions[id];
|
||||||
|
group.position.x = x;
|
||||||
|
group.position.z = z;
|
||||||
|
});
|
||||||
|
fitOrthographicCamera(camera, width, height, mobile ? 14.5 : tablet ? 15.5 : 16.5);
|
||||||
|
},
|
||||||
|
update: (elapsed) => {
|
||||||
|
markerGroups.forEach((group, id) => {
|
||||||
|
const hovered = Boolean(group.userData.hovered);
|
||||||
|
const desiredScale = hovered ? 1.1 : 1;
|
||||||
|
group.scale.setScalar(group.scale.x + (desiredScale - group.scale.x) * 0.13);
|
||||||
|
const baseY = Number(group.userData.baseY);
|
||||||
|
const idleOffset = reduceMotion ? 0 : Math.sin(elapsed * 0.75 + id.length) * 0.015;
|
||||||
|
const desiredY = baseY + (hovered ? 0.22 : 0) + idleOffset;
|
||||||
|
group.position.y += (desiredY - group.position.y) * 0.14;
|
||||||
|
});
|
||||||
|
if (!reduceMotion) {
|
||||||
|
pencil.rotation.y = Math.sin(elapsed * 0.45) * 0.035;
|
||||||
|
celestial.material.rotation = elapsed * 0.025;
|
||||||
|
camera.position.x += (cameraBase.x + pointer.x * 0.18 - camera.position.x) * 0.025;
|
||||||
|
camera.position.z += (cameraBase.z - pointer.x * 0.14 - camera.position.z) * 0.025;
|
||||||
|
camera.lookAt(0, 0.5, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose: () => {
|
||||||
|
canvas.removeEventListener('pointermove', onPointerMove);
|
||||||
|
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||||||
|
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
canvas.removeEventListener('pointerup', onPointerUp);
|
||||||
|
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||||||
|
canvas.style.cursor = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FooterScene() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return <ThreeCanvas className={styles.footerScene} createScene={createFooterScene} theme={theme} />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
.heroScene,
|
.heroScene,
|
||||||
.aboutScene {
|
.aboutScene,
|
||||||
|
.footerScene {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-page);
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroScene,
|
||||||
|
.aboutScene {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,16 +31,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heroScene canvas,
|
.heroScene canvas,
|
||||||
.aboutScene canvas {
|
.aboutScene canvas,
|
||||||
|
.footerScene canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footerScene canvas {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
.aboutScene {
|
.aboutScene {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footerScene {
|
||||||
|
z-index: 1;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.aboutScene::after {
|
.aboutScene::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
15
tests/footer-scene.test.mjs
Normal file
15
tests/footer-scene.test.mjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('the Three.js footer scene preserves the real contact destinations', async () => {
|
||||||
|
const contacts = await readFile('src/components/footerContacts.ts', 'utf8');
|
||||||
|
const scene = await readFile('src/components/scenes/FooterScene.tsx', 'utf8');
|
||||||
|
|
||||||
|
assert.match(contacts, /tel:\+989388263077/);
|
||||||
|
assert.match(contacts, /linkedin\.com\/in\/amiirkhl/);
|
||||||
|
assert.match(contacts, /https:\/\/git\.amiirkhl\.ir/);
|
||||||
|
assert.match(contacts, /https:\/\/t\.me\/amiirkhl/);
|
||||||
|
assert.match(scene, /Raycaster/);
|
||||||
|
assert.match(scene, /FooterContactId/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user