Files
portfolio/js/main.js
T
arsam a1514f17e5 Import source: js/main
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:15:38 +03:30

545 lines
18 KiB
JavaScript

/* ============================================================
OTIS VANDERMEER — main.js
Three.js ember-field hero + GSAP/Lenis choreography
============================================================ */
import * as THREE from "three";
document.documentElement.classList.add("js");
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const isFinePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
/* ------------------------------------------------------------
Boot: wait for fonts + DOM so SplitLines measures correctly
------------------------------------------------------------ */
const ready = Promise.all([
new Promise((res) => {
if (document.readyState !== "loading") res();
else document.addEventListener("DOMContentLoaded", res, { once: true });
}),
document.fonts ? document.fonts.ready : Promise.resolve(),
]);
ready.then(init);
function init() {
gsap.registerPlugin(ScrollTrigger);
const lenis = initSmoothScroll();
initClock();
initWebGL();
splitAll();
initPreloader();
initHeroIntro();
initScrollReveals();
initWorkList();
initMarquee();
initCounters();
initCursor();
initMagnetic(lenis);
}
/* ------------------------------------------------------------
Smooth scroll (Lenis) driven by GSAP ticker
------------------------------------------------------------ */
function initSmoothScroll() {
if (prefersReduced || typeof Lenis === "undefined") return null;
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
// Anchor links go through Lenis
document.querySelectorAll('a[href^="#"]').forEach((a) => {
a.addEventListener("click", (e) => {
const target = document.querySelector(a.getAttribute("href"));
if (!target) return;
e.preventDefault();
lenis.scrollTo(target, { offset: 0, duration: 1.4 });
});
});
return lenis;
}
/* ------------------------------------------------------------
Rotterdam clock
------------------------------------------------------------ */
function initClock() {
const el = document.querySelector("[data-clock]");
if (!el) return;
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Amsterdam",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
const tick = () => (el.textContent = fmt.format(new Date()));
tick();
setInterval(tick, 1000);
}
/* ------------------------------------------------------------
Three.js — "ember field" shader plane
------------------------------------------------------------ */
function initWebGL() {
const canvas = document.querySelector("[data-webgl]");
if (!canvas) return;
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false });
} catch {
canvas.remove(); // graceful: CSS veil still gives a dark hero
return;
}
const isMobile = window.matchMedia("(max-width: 760px)").matches;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.25 : 1.75));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
uTime: { value: 0 },
uRes: { value: new THREE.Vector2(1, 1) },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uScroll: { value: 0 },
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
void main() { gl_Position = vec4(position, 1.0); }
`,
fragmentShader: /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uMouse;
uniform float uScroll;
// --- simplex-ish value noise + fbm ---
vec2 hash(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(dot(hash(i), f), dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x),
u.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.55;
for (int i = 0; i < 5; i++) {
v += a * noise(p);
p = p * 2.05 + vec2(11.3, 7.7);
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / uRes;
vec2 p = uv;
p.x *= uRes.x / uRes.y;
float t = uTime * 0.05;
// mouse drift
vec2 m = (uMouse - 0.5) * 0.35;
// domain-warped fbm: slow molten flow
vec2 q = vec2(fbm(p * 1.4 + t + m), fbm(p * 1.4 - t * 0.7));
vec2 r = vec2(fbm(p * 1.8 + q * 1.6 + vec2(1.7, 9.2) + t * 0.6),
fbm(p * 1.8 + q * 1.6 + vec2(8.3, 2.8) - t * 0.4));
float f = fbm(p * 2.0 + r * 1.8);
// ridges -> thin ember veins
float vein = smoothstep(0.42, 0.5, f) * smoothstep(0.62, 0.5, f);
// palette: warm black -> deep rust -> ember -> pale gold
vec3 ink = vec3(0.047, 0.043, 0.035);
vec3 rust = vec3(0.30, 0.10, 0.04);
vec3 ember = vec3(1.0, 0.30, 0.14);
vec3 gold = vec3(1.0, 0.78, 0.5);
vec3 col = ink;
col = mix(col, rust, smoothstep(0.15, 0.75, f) * 0.85);
col = mix(col, ember, vein * 0.9);
col += gold * pow(vein, 3.0) * 0.6;
// glow pool near the mouse
float md = distance(uv, uMouse);
col += ember * 0.10 * smoothstep(0.45, 0.0, md) * (0.5 + 0.5 * f);
// top fade + vignette so type stays readable
col *= smoothstep(-0.15, 0.55, uv.y) * 0.9 + 0.1;
float vig = smoothstep(1.25, 0.45, length(uv - vec2(0.5, 0.42)));
col *= mix(0.55, 1.0, vig);
// scroll dims the field as the hero leaves
col *= 1.0 - uScroll * 0.85;
gl_FragColor = vec4(col, 1.0);
}
`,
});
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
const hero = document.querySelector("[data-hero]");
const mouseTarget = new THREE.Vector2(0.5, 0.5);
function resize() {
const w = hero.clientWidth, h = hero.clientHeight;
renderer.setSize(w, h, false);
renderer.getDrawingBufferSize(uniforms.uRes.value);
}
resize();
window.addEventListener("resize", resize);
if (isFinePointer) {
window.addEventListener("pointermove", (e) => {
mouseTarget.set(e.clientX / innerWidth, 1.0 - e.clientY / innerHeight);
});
}
// dim shader as hero scrolls away + pause when offscreen
let heroVisible = true;
new IntersectionObserver(([entry]) => (heroVisible = entry.isIntersecting), { threshold: 0 }).observe(hero);
const clock = new THREE.Clock();
gsap.ticker.add(() => {
if (!heroVisible) return;
uniforms.uTime.value = clock.getElapsedTime();
uniforms.uMouse.value.lerp(mouseTarget, 0.05);
uniforms.uScroll.value = Math.min(window.scrollY / Math.max(hero.clientHeight, 1), 1);
renderer.render(scene, camera);
});
}
/* ------------------------------------------------------------
Text splitting (words -> measured lines), no club plugins
------------------------------------------------------------ */
function splitToLines(el) {
const text = el.textContent.trim().replace(/\s+/g, " ");
const hasEm = el.querySelector("em");
// preserve <em> styling by re-walking original nodes
const frag = [];
el.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
node.textContent.split(/\s+/).filter(Boolean).forEach((w) => frag.push({ w, em: false }));
} else if (node.nodeName === "BR") {
frag.push({ br: true });
} else {
node.textContent.split(/\s+/).filter(Boolean).forEach((w) =>
frag.push({ w, em: node.nodeName === "EM" }));
}
});
el.innerHTML = "";
const words = frag.map((item) => {
if (item.br) {
const br = document.createElement("br");
el.appendChild(br);
return null;
}
const span = document.createElement("span");
span.style.display = "inline-block";
if (item.em) {
const em = document.createElement("em");
em.textContent = item.w;
span.appendChild(em);
} else span.textContent = item.w;
el.appendChild(span);
el.appendChild(document.createTextNode(" "));
return span;
}).filter(Boolean);
// group by rendered line
const lines = [];
let lastTop = null;
words.forEach((w) => {
const top = w.offsetTop;
if (top !== lastTop) { lines.push([]); lastTop = top; }
lines[lines.length - 1].push(w);
});
el.innerHTML = "";
const lineSpans = lines.map((lineWords) => {
const outer = document.createElement("span");
outer.className = "split-line";
const inner = document.createElement("span");
inner.innerHTML = lineWords.map((w) => w.outerHTML).join(" ");
outer.appendChild(inner);
el.appendChild(outer);
return inner;
});
el.classList.add("is-split");
return lineSpans;
}
function splitAll() {
document.querySelectorAll("[data-reveal-lines]").forEach((el) => {
el._lines = splitToLines(el);
});
document.querySelectorAll("[data-reveal-words]").forEach((el) => {
el.classList.add("is-split"); // simple fade targets
});
}
/* ------------------------------------------------------------
Preloader
------------------------------------------------------------ */
function initPreloader() {
const pre = document.querySelector(".preloader");
if (!pre) return;
const countEl = pre.querySelector("[data-preloader-count]");
const barEl = pre.querySelector("[data-preloader-bar]");
const wordEl = pre.querySelector("[data-preloader-word]");
const words = ["interfaces", "systems", "motion", "obsession"];
document.body.style.overflow = "hidden";
const state = { n: 0 };
const tl = gsap.timeline({
onComplete: () => {
document.body.style.overflow = "";
pre.remove();
window._heroIntro && window._heroIntro.play();
ScrollTrigger.refresh();
},
});
tl.to(state, {
n: 100,
duration: prefersReduced ? 0.1 : 1.9,
ease: "power3.inOut",
onUpdate: () => {
const v = Math.round(state.n);
countEl.textContent = v;
barEl.style.width = v + "%";
wordEl.textContent = words[Math.min(Math.floor(v / 26), words.length - 1)];
},
});
tl.to(pre, {
clipPath: "inset(0 0 100% 0)",
duration: prefersReduced ? 0.1 : 0.9,
ease: "expo.inOut",
}, "+=0.15");
}
/* ------------------------------------------------------------
Hero intro (played by preloader)
------------------------------------------------------------ */
function initHeroIntro() {
const lines = gsap.utils.toArray("[data-hero-line]");
const fades = gsap.utils.toArray("[data-hero-fade]");
const kicker = document.querySelector(".hero__kicker");
gsap.set(lines, { yPercent: 110 });
if (kicker) gsap.set(kicker, { opacity: 0, y: 14 });
const tl = gsap.timeline({ paused: true });
tl.to(lines, {
yPercent: 0,
duration: prefersReduced ? 0.1 : 1.3,
ease: "expo.out",
stagger: 0.12,
});
tl.to(kicker, { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, "-=0.8");
tl.to(fades, { opacity: 1, duration: 0.9, ease: "power2.out", stagger: 0.07 }, "-=0.6");
window._heroIntro = tl;
}
/* ------------------------------------------------------------
Scroll reveals
------------------------------------------------------------ */
function initScrollReveals() {
// line-by-line masked rise
document.querySelectorAll("[data-reveal-lines]").forEach((el) => {
if (!el._lines) return;
gsap.set(el._lines, { yPercent: 115 });
gsap.to(el._lines, {
yPercent: 0,
duration: 1.1,
ease: "expo.out",
stagger: 0.09,
scrollTrigger: { trigger: el, start: "top 85%" },
});
});
// label fades
document.querySelectorAll("[data-reveal-words]").forEach((el) => {
if (el.closest(".hero")) return; // hero handles its own
gsap.from(el, {
opacity: 0, y: 16, duration: 0.8, ease: "power2.out",
scrollTrigger: { trigger: el, start: "top 88%" },
});
});
// work rows slide in
gsap.utils.toArray("[data-work-item]").forEach((item, i) => {
gsap.from(item.querySelector(".work__row"), {
opacity: 0, y: 40, duration: 0.9, ease: "power3.out",
scrollTrigger: { trigger: item, start: "top 90%" },
});
});
// services
gsap.utils.toArray("[data-service]").forEach((item) => {
gsap.from(item, {
opacity: 0, y: 36, duration: 0.8, ease: "power3.out",
scrollTrigger: { trigger: item, start: "top 90%" },
});
});
// portrait parallax
const portrait = document.querySelector("[data-parallax]");
if (portrait && !prefersReduced) {
gsap.fromTo(portrait, { y: 40 }, {
y: -40, ease: "none",
scrollTrigger: { trigger: portrait, start: "top bottom", end: "bottom top", scrub: 1 },
});
}
// giant CTA lines
gsap.utils.toArray("[data-cta-line]").forEach((line, i) => {
gsap.from(line, {
yPercent: 60, opacity: 0, duration: 1.2, ease: "expo.out", delay: i * 0.08,
scrollTrigger: { trigger: ".contact__cta", start: "top 85%" },
});
});
}
/* ------------------------------------------------------------
Work list: floating thumbnail follows cursor (desktop only)
------------------------------------------------------------ */
function initWorkList() {
if (!isFinePointer) return;
const items = document.querySelectorAll("[data-work-item]");
items.forEach((item) => {
const thumb = item.querySelector("[data-work-thumb]");
if (!thumb) return;
const xTo = gsap.quickTo(thumb, "x", { duration: 0.5, ease: "power3.out" });
const yTo = gsap.quickTo(thumb, "y", { duration: 0.5, ease: "power3.out" });
// thumbs are position:fixed only on wide viewports; skip when static
const floating = () => getComputedStyle(thumb).position === "fixed";
item.addEventListener("pointerenter", (e) => {
if (!floating()) return;
gsap.set(thumb, { x: e.clientX, y: e.clientY });
gsap.to(thumb, {
opacity: 1, scale: 1, rotate: 0, duration: 0.45, ease: "power3.out",
transformOrigin: "center",
});
});
item.addEventListener("pointermove", (e) => {
xTo(e.clientX);
yTo(e.clientY);
});
item.addEventListener("pointerleave", () => {
gsap.to(thumb, { opacity: 0, scale: 0.85, rotate: -4, duration: 0.35, ease: "power2.in" });
});
});
}
/* ------------------------------------------------------------
Marquee: infinite loop + scroll-velocity skew
------------------------------------------------------------ */
function initMarquee() {
const track = document.querySelector("[data-marquee-track]");
if (!track) return;
track.innerHTML += track.innerHTML; // duplicate for seamless wrap
if (prefersReduced) return;
const half = track.scrollWidth / 2;
const loop = gsap.to(track, {
x: -half, duration: 28, ease: "none", repeat: -1,
modifiers: { x: (x) => (parseFloat(x) % half) + "px" },
});
// speed up with scroll velocity
ScrollTrigger.create({
trigger: document.body,
start: 0, end: "max",
onUpdate: (self) => {
const v = gsap.utils.clamp(0.5, 4, Math.abs(self.getVelocity()) / 280);
gsap.to(loop, { timeScale: v, duration: 0.4, overwrite: true });
},
});
}
/* ------------------------------------------------------------
Stat counters
------------------------------------------------------------ */
function initCounters() {
document.querySelectorAll("[data-count]").forEach((el) => {
const target = +el.dataset.count;
const state = { n: 0 };
ScrollTrigger.create({
trigger: el, start: "top 88%", once: true,
onEnter: () =>
gsap.to(state, {
n: target, duration: 1.6, ease: "power3.out",
onUpdate: () => (el.textContent = Math.round(state.n)),
}),
});
});
}
/* ------------------------------------------------------------
Custom cursor
------------------------------------------------------------ */
function initCursor() {
if (!isFinePointer) return;
const root = document.querySelector(".cursor");
const dot = root.querySelector("[data-cursor-dot]");
const ring = root.querySelector("[data-cursor-ring]");
const label = root.querySelector("[data-cursor-label]");
document.body.classList.add("has-cursor");
const dotX = gsap.quickTo(dot, "x", { duration: 0.12, ease: "power2.out" });
const dotY = gsap.quickTo(dot, "y", { duration: 0.12, ease: "power2.out" });
const ringX = gsap.quickTo(ring, "x", { duration: 0.45, ease: "power3.out" });
const ringY = gsap.quickTo(ring, "y", { duration: 0.45, ease: "power3.out" });
window.addEventListener("pointermove", (e) => {
dotX(e.clientX); dotY(e.clientY);
ringX(e.clientX); ringY(e.clientY);
});
document.querySelectorAll("[data-cursor], a, button").forEach((el) => {
el.addEventListener("pointerenter", () => {
const text = el.dataset.cursor;
if (text) {
label.textContent = text;
root.classList.add("is-hover");
}
});
el.addEventListener("pointerleave", () => root.classList.remove("is-hover"));
});
}
/* ------------------------------------------------------------
Magnetic links
------------------------------------------------------------ */
function initMagnetic() {
if (!isFinePointer || prefersReduced) return;
document.querySelectorAll("[data-magnetic]").forEach((el) => {
const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3.out" });
const yTo = gsap.quickTo(el, "y", { duration: 0.4, ease: "power3.out" });
el.addEventListener("pointermove", (e) => {
const r = el.getBoundingClientRect();
xTo((e.clientX - (r.left + r.width / 2)) * 0.35);
yTo((e.clientY - (r.top + r.height / 2)) * 0.35);
});
el.addEventListener("pointerleave", () => { xTo(0); yTo(0); });
});
}