This commit is contained in:
thijs 2026-05-08 11:59:43 +02:00
parent d371c44145
commit e347292cac
3 changed files with 1906 additions and 380 deletions

409
assets/js/custom/modern.js Normal file
View file

@ -0,0 +1,409 @@
/* ============================================================
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();
});
})();