diff --git a/js/mix.js b/js/mix.js new file mode 100644 index 0000000..2fc7e8c --- /dev/null +++ b/js/mix.js @@ -0,0 +1,422 @@ +/* ============================================================ + MIX — "Ink & Ember" creative layer for arsamsabbagh.ir + - Preloader with counter + word cycle + wipe + - Three.js domain-warped "ink & ember" field behind the hero, + riding the site's theme variables (light/dark + accent tweaks) + - Choreographed hero intro (Web Animations API, no GSAP needed) + - Marquee speeds up with scroll velocity + The React app is untouched: this layer only reads its DOM and + re-attaches the canvas when React remounts the hero (lang flip). + ============================================================ */ +import * as THREE from "three"; + +const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; +const EASE_OUT = "cubic-bezier(0.16, 1, 0.3, 1)"; + +/* ------------------------------------------------------------ + Theme palette — read the live CSS variables + ------------------------------------------------------------ */ +function cssColor(name, fallback) { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return new THREE.Color(v || fallback); +} +function readPalette() { + return { + bg: cssColor("--bg", "#F4F0E8"), + accent: cssColor("--accent", "#0F6B73"), + terra: cssColor("--terra", "#B7593C"), + dark: document.documentElement.classList.contains("dark") ? 1 : 0, + }; +} + +/* ------------------------------------------------------------ + Shaders + ------------------------------------------------------------ */ +const VERT = `void main() { gl_Position = vec4(position, 1.0); }`; +const FRAG = ` + precision highp float; + uniform float uTime, uScroll, uDark; + uniform vec2 uRes, uMouse; + uniform vec3 uBg, uAccent, uTerra; + + 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)), f - vec2(1, 0)), u.x), + mix(dot(hash(i + vec2(0, 1)), f - vec2(0, 1)), + dot(hash(i + vec2(1, 1)), f - vec2(1, 1)), u.x), + u.y); + } + // cheap 3-octave fbm for the warp passes + float fbm3(vec2 p) { + float v = 0.0, a = 0.55; + for (int i = 0; i < 3; i++) { + v += a * noise(p); + p = p * 2.05 + vec2(11.3, 7.7); + a *= 0.5; + } + return v; + } + // richer 5-octave fbm only for the final field + 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.045; + vec2 m = (uMouse - 0.5) * 0.3; + + // domain-warped flow: slow ink currents + vec2 q = vec2(fbm3(p * 1.3 + t + m), fbm3(p * 1.3 - t * 0.7)); + vec2 r = vec2(fbm3(p * 1.7 + q * 1.6 + vec2(1.7, 9.2) + t * 0.6), + fbm3(p * 1.7 + q * 1.6 + vec2(8.3, 2.8) - t * 0.4)); + float f = fbm(p * 1.9 + r * 1.8); + float vein = smoothstep(0.42, 0.5, f) * smoothstep(0.62, 0.5, f); + + // art direction: pool the energy toward the lower-right, + // keep the type area (upper-left) close to clean paper + float pool = 0.35 + 0.65 * smoothstep(0.1, 1.1, uv.x + (1.0 - uv.y)); + vein *= pool; + + // light mode: subtle ink-wash on paper · dark mode: glowing currents + float washAmt = mix(0.085, 0.30, uDark); + float veinAmt = mix(0.22, 0.80, uDark); + float coreAmt = mix(0.30, 0.65, uDark); + + vec3 col = uBg; + col = mix(col, mix(uBg, uAccent, 0.85), smoothstep(0.2, 0.85, f) * washAmt); + col = mix(col, uAccent, vein * veinAmt); + col = mix(col, uTerra, pow(vein, 3.0) * coreAmt); + col += uAccent * vein * 0.22 * uDark; + + // soft pool around the pointer + float md = distance(uv, uMouse); + col = mix(col, uAccent, smoothstep(0.4, 0.0, md) * mix(0.05, 0.10, uDark) * (0.5 + 0.5 * f)); + + // keep edges close to paper so type always reads + float vig = smoothstep(1.3, 0.4, length(uv - vec2(0.45, 0.5))); + col = mix(uBg, col, mix(0.55, 1.0, vig)); + + // fade out as the hero scrolls away + col = mix(col, uBg, uScroll * 0.9); + + gl_FragColor = vec4(col, 1.0); + } +`; + +/* ------------------------------------------------------------ + WebGL ember field behind the hero + ------------------------------------------------------------ */ +function initEmber(st) { + const canvas = document.createElement("canvas"); + canvas.className = "mix-ember"; + canvas.setAttribute("aria-hidden", "true"); + + let renderer; + try { + renderer = new THREE.WebGLRenderer({ + canvas, antialias: false, alpha: false, powerPreference: "high-performance", + }); + } catch { + return; // no WebGL: the original SVG mesh hero stays as-is + } + // the field is soft by nature — render at a fraction of CSS pixels + // and let the GPU upscale; this is the main perf lever + const isMobile = window.matchMedia("(max-width: 760px)").matches; + const RENDER_SCALE = isMobile ? 0.5 : 0.62; + renderer.setPixelRatio(1); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const p = readPalette(); + const uniforms = { + uTime: { value: 0 }, + uRes: { value: new THREE.Vector2(1, 1) }, + uMouse: { value: new THREE.Vector2(0.5, 0.5) }, + uScroll: { value: 0 }, + uBg: { value: p.bg.clone() }, + uAccent: { value: p.accent.clone() }, + uTerra: { value: p.terra.clone() }, + uDark: { value: p.dark }, + }; + scene.add(new THREE.Mesh( + new THREE.PlaneGeometry(2, 2), + new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG }) + )); + + const target = readPalette(); + const mouseTarget = new THREE.Vector2(0.5, 0.5); + let hero = null; + let wrap = null; + let heroH = 1; + let lastProgress = -1; + + const ro = new ResizeObserver(() => resize()); + + function mount() { + const h = document.querySelector(".hero"); + if (!h) return; + hero = h; + wrap = h.querySelector(".hero-wrap"); + if (canvas.parentElement !== h) { + hero.prepend(canvas); + hero.classList.add("has-ember"); + } + ro.disconnect(); + ro.observe(hero); // tracks the element box itself — window resize + // events can fire while layout is still settling + resize(); + } + function resize() { + if (!hero) return; + const w = hero.clientWidth, h = hero.clientHeight; + if (w && h) { + heroH = h; + renderer.setSize(Math.round(w * RENDER_SCALE), Math.round(h * RENDER_SCALE), false); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + renderer.getDrawingBufferSize(uniforms.uRes.value); + } + } + + mount(); + // React remounts
on language switch — re-attach the canvas + const rootEl = document.getElementById("root"); + if (rootEl) { + new MutationObserver(() => { if (!canvas.isConnected) mount(); }) + .observe(rootEl, { childList: true, subtree: true }); + } + + // theme / accent changes (mode toggle, tweaks panel) + new MutationObserver(() => Object.assign(target, readPalette())) + .observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] }); + + if (window.matchMedia("(hover: hover)").matches) { + window.addEventListener("pointermove", (e) => { + mouseTarget.set(e.clientX / innerWidth, 1 - e.clientY / innerHeight); + }); + } + + const start = performance.now(); + st.tick.push(() => { + if (!hero || !canvas.isConnected) return; + // hero sits at the top of the page: cached height tells visibility + const progress = Math.min(window.scrollY / heroH, 1); + if (progress >= 1) return; // fully scrolled past — skip GPU work + + // scroll-exit parallax: the hero content drifts up slower than + // the page and quietly lets go + if (wrap && !reduced && Math.abs(progress - lastProgress) > 0.0005) { + wrap.style.transform = `translate3d(0, ${(progress * heroH * 0.22).toFixed(1)}px, 0)`; + wrap.style.opacity = (1 - progress * 0.8).toFixed(3); + } + lastProgress = progress; + + if (!reduced) uniforms.uTime.value = (performance.now() - start) / 1000; + uniforms.uMouse.value.lerp(mouseTarget, 0.05); + uniforms.uScroll.value = progress; + uniforms.uBg.value.lerp(target.bg, 0.07); + uniforms.uAccent.value.lerp(target.accent, 0.07); + uniforms.uTerra.value.lerp(target.terra, 0.07); + uniforms.uDark.value += (target.dark - uniforms.uDark.value) * 0.07; + renderer.render(scene, camera); + }); +} + +/* ------------------------------------------------------------ + Scroll-velocity effects: marquee speeds up, big wordmark + leans into the scroll. Node + animation caches are refreshed + on a slow interval so React remounts (lang flip) are picked up + without per-frame DOM queries. + ------------------------------------------------------------ */ +function initScrollFx(st) { + if (reduced) return; + let last = window.scrollY; + let rate = 1; + let vel = 0; + let mqAnims = []; + let bw = null; + + function recache() { + mqAnims = []; + document.querySelectorAll(".mq-track").forEach((el) => { + mqAnims.push(...el.getAnimations()); + }); + bw = document.querySelector(".bw-text"); + } + recache(); + setInterval(recache, 1200); + + st.tick.push(() => { + const y = window.scrollY; + const dy = y - last; + last = y; + vel += (dy - vel) * 0.1; + + const targetRate = Math.min(4, 1 + Math.abs(vel) * 0.12); + const next = rate + (targetRate - rate) * 0.08; + if (Math.abs(next - rate) > 0.005) { + rate = next; + for (const a of mqAnims) { + try { a.playbackRate = rate; } catch {} + } + } else { + rate = next; + } + + if (bw) { + const skew = Math.max(-5, Math.min(5, -vel * 0.12)); + bw.style.transform = Math.abs(skew) > 0.05 ? `skewX(${skew.toFixed(2)}deg)` : ""; + } + }); +} + +/* ------------------------------------------------------------ + Hero intro + ------------------------------------------------------------ */ +function runHeroIntro() { + const html = document.documentElement; + const lines = document.querySelectorAll(".hero-name .hero-line"); + const groups = [".hero-kicker", ".hero-meta", ".hero-ctas", ".hero-foot"] + .map((s) => document.querySelector(s)).filter(Boolean); + + // hidden tab: rAF/WAAPI are suspended — reveal instantly + if (!lines.length || document.hidden) { html.classList.remove("mix-intro-wait"); return; } + + const anims = []; + lines.forEach((line, i) => { + anims.push(line.animate( + [ + { opacity: 0, transform: "translateY(0.35em)", clipPath: "inset(0 0 100% 0)" }, + { opacity: 1, transform: "translateY(0)", clipPath: "inset(0 0 -15% 0)" }, + ], + { duration: 1100, delay: i * 140, easing: EASE_OUT, fill: "both" } + )); + }); + groups.forEach((el, i) => { + anims.push(el.animate( + [ + { opacity: 0, transform: "translateY(18px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: 800, delay: 350 + i * 110, easing: EASE_OUT, fill: "both" } + )); + }); + + Promise.all(anims.map((a) => a.finished)).catch(() => {}).finally(() => { + html.classList.remove("mix-intro-wait"); + anims.forEach((a) => a.cancel()); + }); +} + +/* ------------------------------------------------------------ + Preloader + ------------------------------------------------------------ */ +function initPreloader() { + const pre = document.getElementById("mix-preloader"); + if (!pre) return; + if (reduced) { pre.remove(); return; } + + document.documentElement.classList.add("mix-intro-wait"); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const countEl = pre.querySelector("[data-mix-count]"); + const barEl = pre.querySelector("[data-mix-bar]"); + const wordEl = pre.querySelector("[data-mix-word]"); + const words = ["intelligence", "evaluation", "inference", "interfaces"]; + + const reactReady = new Promise((res) => { + const check = () => { + if (document.querySelector(".hero-name")) res(); + else setTimeout(check, 40); + }; + check(); + }); + const fontsReady = document.fonts ? document.fonts.ready : Promise.resolve(); + const assetsReady = Promise.all([reactReady, fontsReady]); + + const T = 1500; + const t0 = performance.now(); + let done = false; + + function update(now) { + const lin = Math.min((now - t0) / T, 1); + const eased = 1 - Math.pow(1 - lin, 3); + const v = Math.round(eased * 100); + countEl.textContent = v; + barEl.style.width = v + "%"; + wordEl.textContent = words[Math.min(Math.floor(v / 26), words.length - 1)]; + if (lin >= 1) assetsReady.then(finish); + return lin >= 1; + } + function frame(now) { + if (done) return; + if (!update(now)) requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + // rAF is suspended in hidden tabs — keep the clock moving with a + // throttled interval so a background load still completes + const fallback = setInterval(() => { + if (done) { clearInterval(fallback); return; } + update(performance.now()); + }, 300); + + function finish() { + if (done) return; + done = true; + clearInterval(fallback); + + const reveal = () => { + pre.remove(); + document.body.style.overflow = prevOverflow; + runHeroIntro(); + }; + if (document.hidden) { reveal(); return; } // skip the wipe off-screen + + const wipe = pre.animate( + [{ clipPath: "inset(0 0 0% 0)" }, { clipPath: "inset(0 0 100% 0)" }], + { duration: 750, easing: "cubic-bezier(0.87, 0, 0.13, 1)", fill: "forwards", delay: 120 } + ); + wipe.finished.catch(() => {}).finally(reveal); + } +} + +/* ------------------------------------------------------------ + Boot + ------------------------------------------------------------ */ +const state = { tick: [] }; + +function rafLoop() { + state.tick.forEach((fn) => fn()); + requestAnimationFrame(rafLoop); +} + +function boot() { + initPreloader(); + initEmber(state); + initScrollFx(state); + requestAnimationFrame(rafLoop); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot, { once: true }); +} else { + boot(); +}