feat(footer): add isometric Three.js contact scene
This commit is contained in:
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,
|
||||
.aboutScene {
|
||||
.aboutScene,
|
||||
.footerScene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.heroScene,
|
||||
.aboutScene {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -26,16 +31,27 @@
|
||||
}
|
||||
|
||||
.heroScene canvas,
|
||||
.aboutScene canvas {
|
||||
.aboutScene canvas,
|
||||
.footerScene canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.footerScene canvas {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.aboutScene {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.footerScene {
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.aboutScene::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user