409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
/* ============================================================
|
|
OPZEKER IT — MODERN JS
|
|
- Three.js animated WebGL hero (particle network / torus knot)
|
|
- Typed text rotator
|
|
- Scroll reveal (IntersectionObserver)
|
|
- Spotlight hover on service cards
|
|
- Magnetic nav, sticky header, scrollspy, smooth scroll
|
|
- Counter up, skill bars animation
|
|
============================================================ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ---------- Helpers ---------- */
|
|
const $ = (s, r = document) => r.querySelector(s);
|
|
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
/* ============================================================
|
|
1. THREE.JS WEBGL HERO BACKGROUND
|
|
Dynamic particle network + glowing torus knot
|
|
============================================================ */
|
|
function initWebGLHero() {
|
|
const container = document.getElementById('mx-webgl');
|
|
if (!container || typeof THREE === 'undefined') return;
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(
|
|
55,
|
|
container.clientWidth / container.clientHeight,
|
|
0.1,
|
|
1000
|
|
);
|
|
camera.position.z = 18;
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
alpha: true,
|
|
powerPreference: 'high-performance'
|
|
});
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
renderer.setClearColor(0x000000, 0);
|
|
container.appendChild(renderer.domElement);
|
|
|
|
/* ----- Glowing torus knot (center hero object) ----- */
|
|
const knotGeo = new THREE.TorusKnotGeometry(3.2, 0.55, 220, 28, 2, 3);
|
|
const knotMat = new THREE.MeshBasicMaterial({
|
|
color: 0x9600f4,
|
|
wireframe: true,
|
|
transparent: true,
|
|
opacity: 0.28
|
|
});
|
|
const knot = new THREE.Mesh(knotGeo, knotMat);
|
|
scene.add(knot);
|
|
|
|
// Second ghostly knot for parallax
|
|
const knot2Mat = new THREE.MeshBasicMaterial({
|
|
color: 0x00f2ff,
|
|
wireframe: true,
|
|
transparent: true,
|
|
opacity: 0.18
|
|
});
|
|
const knot2 = new THREE.Mesh(knotGeo, knot2Mat);
|
|
knot2.scale.setScalar(1.15);
|
|
scene.add(knot2);
|
|
|
|
/* ----- Particle starfield ----- */
|
|
const particleCount = prefersReducedMotion ? 400 : 1400;
|
|
const positions = new Float32Array(particleCount * 3);
|
|
const colors = new Float32Array(particleCount * 3);
|
|
const colorA = new THREE.Color(0x00f2ff);
|
|
const colorB = new THREE.Color(0xd42bff);
|
|
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const i3 = i * 3;
|
|
// distribute in a large sphere shell
|
|
const r = 10 + Math.random() * 30;
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const phi = Math.acos(2 * Math.random() - 1);
|
|
positions[i3] = r * Math.sin(phi) * Math.cos(theta);
|
|
positions[i3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
|
positions[i3 + 2] = r * Math.cos(phi);
|
|
|
|
const c = colorA.clone().lerp(colorB, Math.random());
|
|
colors[i3] = c.r;
|
|
colors[i3 + 1] = c.g;
|
|
colors[i3 + 2] = c.b;
|
|
}
|
|
|
|
const pGeo = new THREE.BufferGeometry();
|
|
pGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
pGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
|
|
const pMat = new THREE.PointsMaterial({
|
|
size: 0.08,
|
|
vertexColors: true,
|
|
transparent: true,
|
|
opacity: 0.9,
|
|
sizeAttenuation: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false
|
|
});
|
|
const points = new THREE.Points(pGeo, pMat);
|
|
scene.add(points);
|
|
|
|
/* ----- Interaction: mouse parallax ----- */
|
|
const target = { x: 0, y: 0 };
|
|
const current = { x: 0, y: 0 };
|
|
|
|
container.parentElement.addEventListener('mousemove', (e) => {
|
|
const rect = container.getBoundingClientRect();
|
|
target.x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
|
|
target.y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
|
|
});
|
|
|
|
/* ----- Resize ----- */
|
|
function onResize() {
|
|
const w = container.clientWidth;
|
|
const h = container.clientHeight;
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(w, h);
|
|
}
|
|
window.addEventListener('resize', onResize);
|
|
|
|
/* ----- Animate ----- */
|
|
let rafId;
|
|
const clock = new THREE.Clock();
|
|
function animate() {
|
|
const t = clock.getElapsedTime();
|
|
|
|
// Smooth mouse lerp
|
|
current.x += (target.x - current.x) * 0.05;
|
|
current.y += (target.y - current.y) * 0.05;
|
|
|
|
// Rotate knots
|
|
knot.rotation.x = t * 0.15 + current.y * 0.4;
|
|
knot.rotation.y = t * 0.22 + current.x * 0.4;
|
|
knot2.rotation.x = -t * 0.10 + current.y * 0.25;
|
|
knot2.rotation.y = -t * 0.17 + current.x * 0.25;
|
|
|
|
// Rotate particle field slowly
|
|
points.rotation.y = t * 0.03 + current.x * 0.15;
|
|
points.rotation.x = current.y * 0.15;
|
|
|
|
// Subtle camera breathing
|
|
camera.position.z = 18 + Math.sin(t * 0.3) * 0.4;
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
renderer.render(scene, camera);
|
|
rafId = requestAnimationFrame(animate);
|
|
}
|
|
animate();
|
|
|
|
// Pause when tab hidden
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) cancelAnimationFrame(rafId);
|
|
else animate();
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
2. TYPED ROTATOR
|
|
============================================================ */
|
|
function initTyped() {
|
|
const el = $('#mx-typed');
|
|
if (!el) return;
|
|
|
|
const words = JSON.parse(el.dataset.words || '[]');
|
|
if (!words.length) return;
|
|
|
|
const cursor = document.createElement('span');
|
|
cursor.className = 'typed-cursor';
|
|
cursor.textContent = '|';
|
|
|
|
const textSpan = document.createElement('span');
|
|
el.appendChild(textSpan);
|
|
el.appendChild(cursor);
|
|
|
|
let i = 0, j = 0, deleting = false;
|
|
|
|
function tick() {
|
|
const word = words[i];
|
|
if (!deleting) {
|
|
textSpan.textContent = word.slice(0, ++j);
|
|
if (j === word.length) {
|
|
deleting = true;
|
|
return setTimeout(tick, 1600);
|
|
}
|
|
} else {
|
|
textSpan.textContent = word.slice(0, --j);
|
|
if (j === 0) {
|
|
deleting = false;
|
|
i = (i + 1) % words.length;
|
|
}
|
|
}
|
|
setTimeout(tick, deleting ? 40 : 70);
|
|
}
|
|
tick();
|
|
}
|
|
|
|
/* ============================================================
|
|
3. HEADER SHRINK + SCROLLSPY + SMOOTH SCROLL + BURGER
|
|
============================================================ */
|
|
function initHeader() {
|
|
const header = $('.mx-header');
|
|
const navLinks = $$('.mx-nav-links a[href^="#"]');
|
|
const sections = navLinks
|
|
.map(a => document.getElementById(a.getAttribute('href').slice(1)))
|
|
.filter(Boolean);
|
|
|
|
function onScroll() {
|
|
if (window.scrollY > 30) header.classList.add('scrolled');
|
|
else header.classList.remove('scrolled');
|
|
|
|
// scrollspy
|
|
const scroll = window.scrollY + 120;
|
|
let activeId = null;
|
|
sections.forEach(sec => {
|
|
if (sec.offsetTop <= scroll) activeId = sec.id;
|
|
});
|
|
navLinks.forEach(a => {
|
|
a.classList.toggle('active', a.getAttribute('href') === '#' + activeId);
|
|
});
|
|
}
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
onScroll();
|
|
|
|
// Smooth scroll for internal links
|
|
$$('a[href^="#"]').forEach(a => {
|
|
a.addEventListener('click', (e) => {
|
|
const id = a.getAttribute('href');
|
|
if (id === '#' || id.length < 2) return;
|
|
const target = document.querySelector(id);
|
|
if (!target) return;
|
|
e.preventDefault();
|
|
const y = target.getBoundingClientRect().top + window.scrollY - 70;
|
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
|
|
|
// close mobile nav
|
|
const nav = $('.mx-nav-links');
|
|
const burger = $('.mx-burger');
|
|
if (nav && nav.classList.contains('mobile-open')) {
|
|
nav.classList.remove('mobile-open');
|
|
burger && burger.classList.remove('open');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Burger
|
|
const burger = $('.mx-burger');
|
|
const nav = $('.mx-nav-links');
|
|
if (burger && nav) {
|
|
burger.addEventListener('click', () => {
|
|
burger.classList.toggle('open');
|
|
nav.classList.toggle('mobile-open');
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
4. SCROLL REVEAL
|
|
============================================================ */
|
|
function initReveal() {
|
|
const els = $$('.reveal');
|
|
if (!('IntersectionObserver' in window)) {
|
|
els.forEach(el => el.classList.add('is-visible'));
|
|
return;
|
|
}
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('is-visible');
|
|
io.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.12, rootMargin: '0px 0px -50px 0px' });
|
|
els.forEach(el => io.observe(el));
|
|
}
|
|
|
|
/* ============================================================
|
|
5. SPOTLIGHT HOVER (service cards)
|
|
============================================================ */
|
|
function initSpotlight() {
|
|
$$('.mx-service-card').forEach(card => {
|
|
card.addEventListener('mousemove', (e) => {
|
|
const r = card.getBoundingClientRect();
|
|
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
|
|
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
6. TILT on cards (subtle 3D)
|
|
============================================================ */
|
|
function initTilt() {
|
|
if (prefersReducedMotion) return;
|
|
$$('[data-tilt]').forEach(el => {
|
|
const maxTilt = parseFloat(el.dataset.tilt) || 6;
|
|
el.style.transformStyle = 'preserve-3d';
|
|
el.addEventListener('mousemove', (e) => {
|
|
const r = el.getBoundingClientRect();
|
|
const px = (e.clientX - r.left) / r.width - 0.5;
|
|
const py = (e.clientY - r.top) / r.height - 0.5;
|
|
el.style.transform = `perspective(900px) rotateY(${px * maxTilt}deg) rotateX(${-py * maxTilt}deg) translateY(-6px)`;
|
|
});
|
|
el.addEventListener('mouseleave', () => {
|
|
el.style.transform = '';
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
7. MAGNETIC buttons
|
|
============================================================ */
|
|
function initMagnetic() {
|
|
if (prefersReducedMotion) return;
|
|
$$('[data-magnetic]').forEach(el => {
|
|
const strength = parseFloat(el.dataset.magnetic) || 0.3;
|
|
el.addEventListener('mousemove', (e) => {
|
|
const r = el.getBoundingClientRect();
|
|
const mx = e.clientX - (r.left + r.width / 2);
|
|
const my = e.clientY - (r.top + r.height / 2);
|
|
el.style.transform = `translate(${mx * strength}px, ${my * strength}px)`;
|
|
});
|
|
el.addEventListener('mouseleave', () => {
|
|
el.style.transform = '';
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
8. COUNTER UP + SKILL BARS (on reveal)
|
|
============================================================ */
|
|
function initCounters() {
|
|
if (!('IntersectionObserver' in window)) return;
|
|
|
|
// Numbers
|
|
$$('[data-count]').forEach(el => {
|
|
const target = parseFloat(el.dataset.count);
|
|
const duration = parseInt(el.dataset.duration || '1800', 10);
|
|
const suffix = el.dataset.suffix || '';
|
|
const prefix = el.dataset.prefix || '';
|
|
let started = false;
|
|
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting && !started) {
|
|
started = true;
|
|
const start = performance.now();
|
|
function step(now) {
|
|
const t = Math.min(1, (now - start) / duration);
|
|
const eased = 1 - Math.pow(1 - t, 3);
|
|
const val = target * eased;
|
|
const display = Number.isInteger(target)
|
|
? Math.round(val)
|
|
: val.toFixed(1);
|
|
el.textContent = prefix + display + suffix;
|
|
if (t < 1) requestAnimationFrame(step);
|
|
}
|
|
requestAnimationFrame(step);
|
|
io.unobserve(el);
|
|
}
|
|
});
|
|
}, { threshold: 0.5 });
|
|
io.observe(el);
|
|
});
|
|
|
|
// Skill bars
|
|
$$('.mx-skill-bar').forEach(bar => {
|
|
const pct = parseFloat(bar.dataset.value || '0');
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
bar.style.width = pct + '%';
|
|
io.unobserve(bar);
|
|
}
|
|
});
|
|
}, { threshold: 0.3 });
|
|
io.observe(bar);
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
9. MARQUEE DUPLICATION (seamless loop)
|
|
============================================================ */
|
|
function initMarquee() {
|
|
$$('.mx-marquee-track').forEach(track => {
|
|
const clone = track.innerHTML;
|
|
track.innerHTML = clone + clone;
|
|
});
|
|
}
|
|
|
|
/* ============================================================
|
|
BOOT
|
|
============================================================ */
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initHeader();
|
|
initReveal();
|
|
initSpotlight();
|
|
initTilt();
|
|
initMagnetic();
|
|
initCounters();
|
|
initMarquee();
|
|
initTyped();
|
|
initWebGLHero();
|
|
});
|
|
})();
|