פני מים

רקע שמזמין נגיעה. האדוות הן תגובה ישירה למשתמש, ולכן הן פועלות גם כשהתנועה האוטומטית כבויה — בדיוק ההבחנה שגורמת לאתר להרגיש קשוב ולא רועש. פני מים הוא רכיב מתוך רכיבים של כליקס. מה שמורידים הוא רכיב שלם, גם כ-HTML עצמאי וגם כרכיב React. אין ספרייה חיצונית, אין שלב בנייה, ואם כליקס ייעלם מחר הקוד ימשיך לעבוד.

רכיב · WebGL · בלי ספריות · רקע

<!-- פני מים — הקנבס נמתח על ההורה, שצריך להיות position:relative -->
<canvas id="klix-water" aria-hidden="true"></canvas>

<style>
  /* הגרדיאנט הזה הוא מה שנשאר אם אין WebGL2 */
  #klix-water{position:absolute;inset:0;width:100%;height:100%;
    background:linear-gradient(180deg,#0b2540,#04101f)}
</style>

<script>
(function () {
  var canvas = document.getElementById('klix-water');
  if (!canvas) return;

  var FRAGMENT = "#version 300 es\nprecision highp float;\nout vec4 outColor;\nuniform vec2 u_res;\nuniform float u_time;\nuniform vec2 u_mouse;\n\nfloat hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n\nfloat noise(vec2 p){\n  vec2 i = floor(p); vec2 f = fract(p);\n  float a = hash(i), b = hash(i + vec2(1.0, 0.0));\n  float c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\n// ארבע אוקטבות ולא שש. ההבדל הוויזואלי ברקע מטושטש זניח,\n// והחיסכון משמעותי כששיידר רץ על מסך מלא.\nfloat fbm(vec2 p){\n  float v = 0.0; float amp = 0.5;\n  for (int i = 0; i < 4; i++) { v += amp * noise(p); p *= 2.02; amp *= 0.5; }\n  return v;\n}\n\nuniform vec3 u_ripples[4];\n// זמן אמת שרץ תמיד. האדוות הן תגובה ישירה לנגיעה של המשתמש, ולכן הן\n// מתנגנות גם כשהתנועה כבויה, בניגוד לגלים שברקע.\nuniform float u_rtime;\n\nfloat surface(vec2 uv, float t){\n  float h = fbm(uv * 2.4 + vec2(t * 0.16, t * 0.09));\n  h += 0.5 * fbm(uv * 5.0 - vec2(t * 0.22, t * 0.13));\n\n  // האצבע גוררת מערבולת קטנה על פני המים\n  float d = length(uv - u_mouse);\n  h += 0.18 * sin(d * 26.0 - t * 5.0) * exp(-d * 5.5);\n\n  for (int i = 0; i < 4; i++){\n    float age = u_rtime - u_ripples[i].z;\n    if (u_ripples[i].z < 0.0 || age > 3.2) continue;\n    float dr = length(uv - u_ripples[i].xy);\n    float ring = sin(dr * 34.0 - age * 9.0);   // טבעת שמתרחקת ודועכת\n    h += 0.5 * ring * exp(-dr * 5.0) * exp(-age * 1.3);\n  }\n  return h;\n}\n\nvoid main(){\n  vec2 uv = (gl_FragCoord.xy - 0.5 * u_res.xy) / u_res.y;\n  float t = u_time;\n\n  // נורמל מקורב מהפרש גבהים. אפסילון גדול יחסית, אחרת הקאוסטיקה יוצאת\n  // רועשת ומקשה על קריאת הטקסט שמעליה.\n  float e = 0.02;\n  float hC = surface(uv, t);\n  float hX = surface(uv + vec2(e, 0.0), t);\n  float hY = surface(uv + vec2(0.0, e), t);\n  vec2 grad = vec2(hX - hC, hY - hC) / e;\n\n  vec3 deep    = vec3(0.008, 0.031, 0.070);\n  vec3 shallow = vec3(0.043, 0.157, 0.290);\n  vec3 foam    = vec3(0.545, 0.788, 0.965);\n  vec3 gold    = vec3(0.784, 0.643, 0.082);\n\n  float depth = smoothstep(-0.6, 1.8, hC);\n  vec3 col = mix(deep, shallow, depth);\n\n  // קאוסטיקה: קווי אור במקומות שבהם פני המים מתכנסים. עדין בכוונה —\n  // הרקע כאן משרת את הטקסט ולא להפך.\n  float caustic = pow(max(0.0, 1.0 - length(grad) * 0.55), 8.0);\n  col += foam * caustic * 0.20;\n\n  col += gold * smoothstep(1.05, 1.6, hC) * 0.14;   // הבהוב על פסגות הגלים\n\n  float vig = smoothstep(1.35, 0.2, length(uv));\n  col *= mix(0.35, 1.0, vig);\n\n  outColor = vec4(col, 1.0);\n}";

  var MAX_RIPPLES = 4;
  // vec3 לכל אדווה: x, y, וזמן הלידה. זמן שלילי פירושו משבצת ריקה.
  var ripples = new Float32Array(MAX_RIPPLES * 3).fill(-1);
  var slot = 0;
  var clock = 0;

  function drop(p) {
    var i = (slot % MAX_RIPPLES) * 3;
    ripples[i] = p.nx;
    ripples[i + 1] = p.ny;
    ripples[i + 2] = clock;
    slot++;
  }

  function uniforms(f, gl, uloc) {
    // שעון אמת שרץ תמיד. האדוות הן תגובה ישירה לנגיעה, ולכן הן
    // מתנגנות גם כשהתנועה האוטומטית כבויה.
    clock = f.t;
    gl.uniform1f(uloc('u_rtime'), f.t);
    gl.uniform3fv(uloc('u_ripples'), ripples);
  }

  mountShader(canvas, FRAGMENT, uniforms, drop);

  // מריץ fragment shader על מלבן שמכסה את הקנבס. מחזיר פונקציית עצירה,
  // או null אם אין WebGL2 — ואז נשאר רקע ה-CSS שמוגדר על הקנבס.
  function mountShader(canvas, fragment, extra, onDown) {
    var gl = canvas.getContext('webgl2', { antialias: false, alpha: true });
    if (!gl) return null;   // אין WebGL2 — נשארים עם רקע ה-CSS
  
    function compile(src, type) {
      var s = gl.createShader(type);
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        console.error('shader compile failed:', gl.getShaderInfoLog(s));
        return null;
      }
      return s;
    }
  
    var vs = compile('#version 300 es\nin vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}',
                     gl.VERTEX_SHADER);
    var fs = compile(fragment, gl.FRAGMENT_SHADER);
    if (!vs || !fs) return null;
  
    var prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.error('program link failed:', gl.getProgramInfoLog(prog));
      return null;
    }
    gl.useProgram(prog);
  
    var buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
    var loc = gl.getAttribLocation(prog, 'a_pos');
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
  
    var cache = {};
    function uloc(name) {
      if (!(name in cache)) cache[name] = gl.getUniformLocation(prog, name);
      return cache[name];
    }
  
    // שעון שמתקדם רק כשהתנועה דלוקה, ונבדק בכל פריים כדי שמתג יגיב מיד.
    // הסמן נשאר חי תמיד, כי הוא מונע ישירות על ידי המשתמש.
    var clock = 0;
    var smooth = { x: 0, y: 0 };
  
    return mountStage(canvas, {
      // רקעים ערפיליים נראים זהים ב-dpr 1 ומתיחה ב-CSS, וזה חוסך
      // פי ארבעה עבודה במסכים צפופים.
      maxDpr: 1,
      onResize: function (w, h) {
        gl.viewport(0, 0, w, h);
        gl.uniform2f(uloc('u_res'), w, h);
      },
      onPointerDown: onDown,
      onFrame: function (f) {
        if (motionEnabled()) clock += f.dt;
        smooth.x += (f.pointer.nx - smooth.x) * 0.06;
        smooth.y += (f.pointer.ny - smooth.y) * 0.06;
        gl.uniform1f(uloc('u_time'), clock);
        gl.uniform2f(uloc('u_mouse'), smooth.x, smooth.y);
        if (extra) extra(f, gl, uloc);
        gl.drawArrays(gl.TRIANGLES, 0, 6);
      }
    });
  }

  // תשתית הקנבס: מדידת גודל, השהיה מחוץ למסך, מעקב אחרי המצביע ושעון dt חסום
  function mountStage(canvas, opts) {
    var dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr || 2);
    var pointer = { x: 0, y: 0, nx: 0, ny: 0, inside: false };
    var w = 0, h = 0, onScreen = true, painted = false, running = true, raf = 0;
  
    // ה-CSS לא בהכרח נטען כשהפונקציה רצה, ואז הקנבס נתקע בגודל 0.
    // לכן מודדים בכל פריים ולא רק כשמשנים גודל חלון.
    function syncSize() {
      var cw = Math.round(canvas.clientWidth * dpr);
      var ch = Math.round(canvas.clientHeight * dpr);
      if (!cw || !ch || (canvas.width === cw && canvas.height === ch)) return false;
      canvas.width = cw; canvas.height = ch; w = cw; h = ch;
      if (opts.onResize) opts.onResize(cw, ch, dpr);
      return true;
    }
  
    function readPointer(e) {
      var r = canvas.getBoundingClientRect();
      if (!r.width || !r.height) return;
      pointer.x = (e.clientX - r.left) * dpr;
      pointer.y = (e.clientY - r.top) * dpr;
      pointer.nx = (e.clientX - r.left - r.width / 2) / r.height;
      pointer.ny = (r.height / 2 - (e.clientY - r.top)) / r.height;
      pointer.inside = e.clientX >= r.left && e.clientX <= r.right &&
                       e.clientY >= r.top && e.clientY <= r.bottom;
      if (onScreen) painted = false;   // קלט מהמשתמש תמיד מצדיק ציור מחדש
    }
  
    function onMove(e) { readPointer(e); }
    function onDown(e) {
      readPointer(e);
      if (pointer.inside && opts.onPointerDown) opts.onPointerDown(pointer);
    }
    window.addEventListener('pointermove', onMove, { passive: true });
    window.addEventListener('pointerdown', onDown, { passive: true });
  
    // רץ רק כשהקנבס על המסך. בלי זה כמה אפקטים בדף אחד מתחרים על המעבד.
    var io = new IntersectionObserver(function (entries) { onScreen = entries[0].isIntersecting; });
    io.observe(canvas);
  
    var last = performance.now(), start = last;
  
    function loop() {
      if (!running) return;
      raf = requestAnimationFrame(loop);
      if (syncSize()) painted = false;
      if (!w || !h) return;
  
      // מחוץ למסך: מציירים פריים אחד כדי שלא יישאר ריק, ואז נעצרים
      var idle = document.hidden || !onScreen;
      if (idle && painted) return;
  
      var now = performance.now();
      var dt = Math.min((now - last) / 1000, 1 / 20);
      last = now;
      opts.onFrame({ t: (now - start) / 1000, dt: dt, w: w, h: h, dpr: dpr, pointer: pointer });
      painted = idle;
    }
    loop();
  
    return function () {
      running = false;
      cancelAnimationFrame(raf);
      io.disconnect();
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerdown', onDown);
    };
  }
  
  // התנועה האוטומטית נתלית בתכונה על אלמנט השורש, כדי שמתג באתר יוכל לגבור
  // על הגדרת מערכת ההפעלה. תנועה שהמשתמש מייצר בעצמו פועלת תמיד.
  function motionEnabled() {
    if (document.documentElement.dataset.motion) return document.documentElement.dataset.motion !== 'off';
    return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  }
})();
</script>