(() => { const BG = "#044AB3"; const CFG = { colsDesktop: 18, rowsDesktop: 12, colsMobile: 12, rowsMobile: 9, worldW: 1600, worldH: 1900, camZ: 860, fov: 1280, tiltXTop: 1.28, tiltXBottom: -1.28, bowZ: 420, waveAmp: 18, waveSpeed: 0.65, topAnchorY: 0.00, bottomAnchorY: 1.00, lineAlphaNear: 0.42, lineAlphaFar: 0.12, lineWNear: 1.28, lineWFar: 0.52, dotRNear: 2.4, dotRFar: 1.0, dotAlphaNear: 0.92, dotAlphaFar: 0.24, cursorSmooth: 0.045, cursorRadiusNorm: 0.26, cursorZBump: 45, cursorWarpY: 22, cursorRippleAmp: 10, cursorTiltX: 0.10, cursorTiltY: 0.16 }; const wrap = document.getElementById("strings-wrap"); const canvas = document.getElementById("strings-canvas"); const ctx = canvas.getContext("2d", { alpha: false }); let w = 1, h = 1, cx = 0, dpr = 1; let cols = CFG.colsDesktop, rows = CFG.rowsDesktop; let raf = 0; const mouse = { active: false, tx: 0.5, ty: 0.5, sx: 0.5, sy: 0.5, yaw: 0, pitch: 0 }; const isMobile = () => window.matchMedia("(max-width: 767px)").matches; const lerp = (a, b, t) => a + (b - a) * t; const clamp01 = (v) => Math.max(0, Math.min(1, v)); function resize() { const r = wrap.getBoundingClientRect(); w = Math.max(1, r.width); h = Math.max(1, r.height); cx = w * 0.5; cols = isMobile() ? CFG.colsMobile : CFG.colsDesktop; rows = isMobile() ? CFG.rowsMobile : CFG.rowsDesktop; dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function rotateX(p, a) { const c = Math.cos(a), s = Math.sin(a); return { x: p.x, y: p.y * c - p.z * s, z: p.y * s + p.z * c }; } function rotateY(p, a) { const c = Math.cos(a), s = Math.sin(a); return { x: p.x * c + p.z * s, y: p.y, z: -p.x * s + p.z * c }; } function project(p, anchorY) { const zc = p.z + CFG.camZ; const k = CFG.fov / Math.max(1, zc); return { x: cx + p.x * k, y: anchorY + p.y * k, zc }; } function cursorInfluence(tx, ty) { const dx = tx - mouse.sx; const dy = ty - mouse.sy; const d = Math.hypot(dx, dy); if (d >= CFG.cursorRadiusNorm) return { inf: 0, dx, dy, d }; const f = 1 - d / CFG.cursorRadiusNorm; return { inf: f * f, dx, dy, d }; } function gridPoint(ix, iy, t, topPanel) { const tx = ix / (cols - 1); const ty = iy / (rows - 1); let x = (tx - 0.5) * CFG.worldW; let y = (ty - 0.5) * CFG.worldH; const nx = tx - 0.5; const ny = ty - 0.5; const bowl = (nx * nx + ny * ny) * CFG.bowZ; const wave = Math.sin((tx * 4.0 + ty * 2.6) + t * CFG.waveSpeed) * CFG.waveAmp; let z = topPanel ? (bowl + wave) : (bowl - wave); const ci = cursorInfluence(tx, ty); if (ci.inf > 0) { const side = topPanel ? 1 : -1; const ripple = Math.sin((ci.d * 30) - t * 4.2) * CFG.cursorRippleAmp * ci.inf; z += side * (CFG.cursorZBump * ci.inf + ripple); y += (mouse.sy - ty) * CFG.cursorWarpY * ci.inf; } return { x, y, z }; } function drawSeg(a, b) { const zMid = (a.zc + b.zc) * 0.5; const depth = clamp01((CFG.camZ + 620 - zMid) / 1240); const lw = lerp(CFG.lineWFar, CFG.lineWNear, depth); const al = lerp(CFG.lineAlphaFar, CFG.lineAlphaNear, depth); ctx.strokeStyle = `rgba(255,255,255,${al.toFixed(3)})`; ctx.lineWidth = lw; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } function drawPanel(t, topPanel) { const pts = Array.from({ length: rows }, () => Array(cols)); const baseTilt = topPanel ? CFG.tiltXTop : CFG.tiltXBottom; const anchorY = h * (topPanel ? CFG.topAnchorY : CFG.bottomAnchorY); const tiltX = baseTilt + mouse.pitch * CFG.cursorTiltX; const tiltY = mouse.yaw * CFG.cursorTiltY; for (let iy = 0; iy < rows; iy++) { for (let ix = 0; ix < cols; ix++) { let p = gridPoint(ix, iy, t, topPanel); p = rotateY(p, tiltY); p = rotateX(p, tiltX); pts[iy][ix] = project(p, anchorY); } } for (let iy = 0; iy < rows; iy++) { for (let ix = 0; ix < cols - 1; ix++) drawSeg(pts[iy][ix], pts[iy][ix + 1]); } for (let ix = 0; ix < cols; ix++) { for (let iy = 0; iy < rows - 1; iy++) drawSeg(pts[iy][ix], pts[iy + 1][ix]); } for (let iy = 0; iy < rows; iy++) { for (let ix = 0; ix < cols; ix++) { const p = pts[iy][ix]; const depth = clamp01((CFG.camZ + 620 - p.zc) / 1240); const r = lerp(CFG.dotRFar, CFG.dotRNear, depth); const a = lerp(CFG.dotAlphaFar, CFG.dotAlphaNear, depth); ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill(); } } } function updateMouseSmooth() { const tx = mouse.active ? mouse.tx : 0.5; const ty = mouse.active ? mouse.ty : 0.5; mouse.sx += (tx - mouse.sx) * CFG.cursorSmooth; mouse.sy += (ty - mouse.sy) * CFG.cursorSmooth; const nx = (mouse.sx - 0.5) * 2; const ny = (mouse.sy - 0.5) * 2; mouse.yaw = nx; mouse.pitch = -ny; } function draw(ts) { const t = ts * 0.001; updateMouseSmooth(); ctx.fillStyle = BG; ctx.fillRect(0, 0, w, h); drawPanel(t, true); drawPanel(t, false); raf = requestAnimationFrame(draw); } function setMouse(clientX, clientY) { const r = canvas.getBoundingClientRect(); mouse.tx = clamp01((clientX - r.left) / Math.max(1, w)); mouse.ty = clamp01((clientY - r.top) / Math.max(1, h)); mouse.active = true; } function start() { cancelAnimationFrame(raf); resize(); raf = requestAnimationFrame(draw); } window.addEventListener("mousemove", (e) => setMouse(e.clientX, e.clientY)); window.addEventListener("mouseout", () => { mouse.active = false; }); window.addEventListener("blur", () => { mouse.active = false; }); canvas.addEventListener("touchstart", (e) => { if (e.touches && e.touches[0]) setMouse(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true }); canvas.addEventListener("touchmove", (e) => { if (e.touches && e.touches[0]) setMouse(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true }); canvas.addEventListener("touchend", () => { mouse.active = false; }); const ro = new ResizeObserver(start); ro.observe(wrap); window.addEventListener("resize", start); start(); })();