שדה רוח

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

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

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

<style>
  #klix-wind{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
</style>

<script>
(function () {
  var canvas = document.getElementById('klix-wind');
  if (!canvas) return;
  var ctx = canvas.getContext('2d');
  if (!ctx) return;

  var COUNT = 340;
  var REPEL = 210;            // רדיוס הדחיפה בפיקסלים לוגיים
  var COLOR = '224, 190, 60'; // RGB של החלקיקים

  var motes = [];

  function spawn(w, h, atEdge) {
    return {
      x: atEdge ? w + Math.random() * 40 : Math.random() * w,
      y: Math.random() * h,
      px: 0, py: 0,
      vx: -(1.4 + Math.random() * 3.0),
      vy: (Math.random() - 0.5) * 0.4,
      len: 18 + Math.random() * 60,
      a: 0.22 + Math.random() * 0.6
    };
  }

  function reset(w, h) {
    motes = [];
    for (var i = 0; i < COUNT; i++) {
      var m = spawn(w, h, false);
      m.px = m.x; m.py = m.y;
      motes.push(m);
    }
  }

  function frame(f) {
    ctx.clearRect(0, 0, f.w, f.h);

    // החלקיקים תמיד מגיבים לסמן. מה שנעצר כשהתנועה כבויה הוא רק הסחף
    // האוטומטי, כך שנוצר שדה אבק שקט שמתפזר רק כשמעבירים בו יד.
    var step = f.dt * 60;
    var drift = motionEnabled() ? 1 : 0;
    var rr = REPEL * (canvas.width / Math.max(canvas.clientWidth, 1));

    for (var i = 0; i < motes.length; i++) {
      var m = motes[i];
      m.px = m.x; m.py = m.y;

      // משב משתנה לאורך הגובה, כדי שלא ייראה כמו פס אחיד
      var gust = Math.sin(m.y * 0.004 + f.t * 0.5) * 0.4;

      if (f.pointer.inside) {
        var dx = m.x - f.pointer.x, dy = m.y - f.pointer.y;
        var d2 = dx * dx + dy * dy;
        if (d2 < rr * rr && d2 > 1) {
          var d = Math.sqrt(d2);
          var force = (1 - d / rr) * 2.4;
          // דחיפה החוצה ועוד רכיב ניצב, שנותן תחושת מערבולת
          m.vx += (dx / d) * force * step * 0.6 - (dy / d) * force * step * 0.25;
          m.vy += (dy / d) * force * step * 0.6 + (dx / d) * force * step * 0.25;
        }
      }

      // חזרה הדרגתית לזרימה הבסיסית. כשהתנועה כבויה הזרימה שואפת לאפס,
      // ולכן חלקיק שנדחף על ידי הסמן פשוט נעצר במקום החדש שלו.
      m.vx += (-2.2 * drift - m.vx) * 0.02 * step;
      m.vy += (gust * drift - m.vy) * 0.02 * step;
      m.x += m.vx * step;
      m.y += m.vy * step;

      if (m.x < -60 || m.y < -60 || m.y > f.h + 60) {
        motes[i] = spawn(f.w, f.h, true);
        motes[i].px = motes[i].x;
        motes[i].py = motes[i].y;
      }
    }

    ctx.lineCap = 'round';
    ctx.lineWidth = 1.5;
    for (var k = 0; k < motes.length; k++) {
      var p = motes[k];
      var speed = Math.hypot(p.x - p.px, p.y - p.py);
      var tail = Math.min(p.len, 8 + speed * 9);
      var dirX = p.vx || -1, dirY = p.vy;
      var norm = Math.hypot(dirX, dirY) || 1;
      var tx = p.x - (dirX / norm) * tail;
      var ty = p.y - (dirY / norm) * tail;

      // הזנב נמוג לאחור, כדי שהחלקיק ייראה כמו משב ולא כמו קו
      var g = ctx.createLinearGradient(p.x, p.y, tx, ty);
      g.addColorStop(0, 'rgba(' + COLOR + ',' + p.a + ')');
      g.addColorStop(1, 'rgba(' + COLOR + ',0)');
      ctx.strokeStyle = g;
      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(tx, ty);
      ctx.stroke();
    }
  }

  mountStage(canvas, { maxDpr: 1.5, onResize: reset, onFrame: frame });

  // תשתית הקנבס: מדידת גודל, השהיה מחוץ למסך, מעקב אחרי המצביע ושעון 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>