feat(footer): implement responsive 3D isometric SVG footer scene with theme tokens
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

This commit is contained in:
2026-09-08 11:31:36 +03:30
parent 0ed36b3c32
commit 88adced09f
19 changed files with 1471 additions and 954 deletions

View File

@@ -1,600 +0,0 @@
'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} />;
}

View File

@@ -1,6 +1,5 @@
.heroScene,
.aboutScene,
.footerScene {
.aboutScene {
position: absolute;
inset: 0;
overflow: hidden;
@@ -31,27 +30,16 @@
}
.heroScene canvas,
.aboutScene canvas,
.footerScene canvas {
.aboutScene 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;
@@ -90,4 +78,3 @@
width: 100vw;
}
}