Import source: js/mix

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:15:39 +03:30
parent a1514f17e5
commit 0d4586f23c
+422
View File
@@ -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 <main> 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();
}